From 73100e5950be3b951a3281b4e0763c244f3b67aa Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 13:00:07 -0400 Subject: [PATCH] feat(beehive): bind private TUI lifecycle to registered host keys Signed-off-by: Logan Johnson --- beehive/PRIVATE_HOST_CATALOG_D2A1427A.md | 90 +++++++++++++++ beehive/src/catalog-transport.ts | 48 ++++++++ beehive/src/cli.ts | 34 ++++-- beehive/src/credential-store.ts | 47 ++++++++ beehive/src/host-catalog.ts | 66 +++++++++++ beehive/src/host-identity.ts | 29 +++-- beehive/src/host-transport.ts | 38 ++++++ beehive/src/host.ts | 9 +- beehive/src/intents.ts | 12 +- beehive/src/nostr-client.ts | 1 + beehive/src/relay-admission.ts | 10 ++ beehive/test/catalog-tui.test.ts | 122 ++++++++++++++++++++ beehive/test/credential-fixture.ts | 11 ++ beehive/test/credential-store.test.ts | 48 ++++++++ beehive/test/enrollment-input.test.ts | 20 ++-- beehive/test/isolated-credentials-loader.ts | 9 ++ beehive/test/isolated-file-credentials.ts | 13 +++ beehive/test/nostr-fixture.ts | 11 +- beehive/test/nostr-transport.test.ts | 18 +-- 19 files changed, 589 insertions(+), 47 deletions(-) create mode 100644 beehive/PRIVATE_HOST_CATALOG_D2A1427A.md create mode 100644 beehive/src/catalog-transport.ts create mode 100644 beehive/src/credential-store.ts create mode 100644 beehive/src/host-catalog.ts create mode 100644 beehive/src/host-transport.ts create mode 100644 beehive/src/relay-admission.ts create mode 100644 beehive/test/catalog-tui.test.ts create mode 100644 beehive/test/credential-fixture.ts create mode 100644 beehive/test/credential-store.test.ts create mode 100644 beehive/test/isolated-credentials-loader.ts create mode 100644 beehive/test/isolated-file-credentials.ts diff --git a/beehive/PRIVATE_HOST_CATALOG_D2A1427A.md b/beehive/PRIVATE_HOST_CATALOG_D2A1427A.md new file mode 100644 index 0000000000..93018aa203 --- /dev/null +++ b/beehive/PRIVATE_HOST_CATALOG_D2A1427A.md @@ -0,0 +1,90 @@ +# Private host catalog continuation (implementation checkpoint, not deployable) + +The existing owner TUI now accepts `tui ` with an explicitly +entered existing owner signer. `catalog ` retains +verified public registrations. Catalog validation binds the signature to owner, +host PUBLIC KEY, relay, expiration and label; duplicate labels are allowed but are +never routes. The new transport uses host public keys as Message.host. Historical +label routes are not silently migrated. + +The owner management journal remains the single intent/receipt owner. Its private +Nostr adapter unwraps and verifies reports against the retained catalog before +inventory or receipt processing. Local encrypted intent envelopes are not broadcast: +their exact inner operation ID/body is rewrapped privately to one addressed host. +Relay ACK is never a terminal result. Expired registrations and unregistered host +responses fail closed; stale or absent inventory is UNKNOWN, not stopped. + +The existing host executor accepts an internal transport dependency. The private +adapter verifies registration, host key, expected owner and command host; executor +startup also binds that transport to retained host/agent authority. Inventory and +receipts go only to the owner. The existing per-agent assignment, journal, +fingerprint, revision and cancellation checks still govern execution. Registration +alone does not initialize agent assignment or authorize an unprovisioned agent. +Move, host-host grants and private profile distribution remain refused. No new app, +registry daemon, HTTP service, production CLI test flag or OA host credential. + +## Executable evidence + +`test/catalog-tui.test.ts` drives the actual CLI/TUI subprocess, existing host +executor and external fixture runner. It observes verified private inventory, +registered-but-unreachable second host, Start/running/Stop/stopped, same-agent +identity and revision 2. A second independently admitted host receives no inventory +or commands; forged host inventory and unauthorized Start do not change state. +An exact retained Start is freshly wrapped after Stop and the journal stays +byte-identical; owner reconcile/reopen history cannot resurrect it. + +The fixture uses independent direct-membership identities, signed NIP-42 AUTH, +1059 signatures/timestamps and authenticated-recipient p-gated reads. It is a narrow +executable model, NOT a real Rust relay or proof that production membership limits +a host to management operations. AUTH uses the source ±60 second window. Tests +inject only the typed admission dependency using an internal Node module loader; +normal CLI input/JSON cannot approve a connection. Production admission always +refuses before dialing. Tests do not sign as a real user or call a provider. + +## Mandatory OS key storage — partial implementation, exact gate + +Source report: workspace `KEY_STORAGE_SOURCE_2D3E2699.md`, pinned Buzz `051c3a270`: +Desktop uses Rust keyring 3.6.3 (macOS native Keychain generic password, Linux Secret +Service, Windows Credential Manager), a service-scoped JSON blob, cross-process +mutation lock and backend read-back verification. Beehive has no vetted TS native +bridge for those primitives. We do NOT access Buzz keychain items or spawn a tool +that might prompt. No OS integration or OS validation is claimed. + +`credential-store.ts` is the minimum create/read/remove/reference boundary with +read-back and absence verification, distinct missing vs unavailable failures and +no implicit file fallback. Its production backend refuses with actionable bridge +requirements. Host identity schema v3 persists only a Beehive public credential +reference, pairing and registration. Host keys are created through the backend +before public metadata; v2 plaintext/OA files fail closed without migration. +Missing/removed keys never regenerate. A metadata-write failure may leave a retained +credential needing explicit recovery; no automatic deletion/recreation is attempted. +The old `identity` CLI command no longer creates a parallel controller key. + +Tests explicitly inject in-memory storage or fresh fixture-only file storage across +pairing subprocesses. These are NOT OS keychain tests or a production fallback. +Production host creation is currently refused until a reviewed native bridge and +separate isolated-namespace validation authority exist. + +**Still unfinished:** legacy diagnostic setup/slot APIs and CLI paths retain their +historical plaintext owner/agent storage. They are not a compliant production path; +do not use them with normal user keys. Converting that manifest, import/removal and +host loaders to public references is required before any installation or release. +The new private host adapter currently proves lifecycle with an explicitly isolated +legacy fixture installation, not a production owner-public-only agent installation. + +## Review and remaining work + +Consumed the independent a8 source report `REAL_HOST_SECURITY_A8E2424E.md`. Its +recommendation to use OA is superseded by the explicit owner requirement: AUTH +ViaOwner materializes an agent-owner relationship and is prohibited for hosts. +The report confirms that ordinary membership is broader than 1059-only. No deployment +permission is inferred from it. This delta removes caller label-map trust from the +new owner client and binds lifecycle dedup to the existing journals; it is not an +independent security review of the delta. + +Remaining implementation: OS-backed host/agent/owner key lifecycle; agent public +installation schema and explicit placement signatures without owner-secret custody; +actual installed Buzz runtime/TS provider signed reply via this transport; private +Restart acceptance, host reconnect, profile distribution and retained-source-authority +Move integration. Current production gate must stay closed throughout. No machine +installations, release pin, mesh branch or production policy changed. diff --git a/beehive/src/catalog-transport.ts b/beehive/src/catalog-transport.ts new file mode 100644 index 0000000000..7b99f08083 --- /dev/null +++ b/beehive/src/catalog-transport.ts @@ -0,0 +1,48 @@ +import { connectNostr } from './nostr-client.ts'; +import { catalogHost, catalogResponse, verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; +import { productionAdmission, type ScopedRelayAdmission } from './relay-admission.ts'; +import { open, publicKey, type Envelope, type Message } from './protocol.ts'; + +/** Adapter for the existing durable owner client. No broadcast, OA, or label routing. + * Admission is injected internally for isolated fixtures; no CLI/env/JSON override. + */ +export function catalogTransport(catalog: HostCatalog, admission: ScopedRelayAdmission = productionAdmission) { + return (url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) => { + const retained = structuredClone(verifyHostCatalog(catalog, publicKey(secret), url)); + admission.admit({ relay: url, publicKey: retained.owner, transport: 'nip42-nip59', ownerDelegation: false }); + let closed = false; + let generation = 0; + function dial() { + const current = ++generation; + return connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => { + if (closed || current !== generation) return; + let m: Message; + try { m = catalogResponse(retained, input); } catch { return; } + receive(m); + }, () => { if (!closed && current === generation) disconnected?.(1008); }); + } + let wire = dial(); + function send(m: Message) { + if (closed) throw Error('UI closed'); + // Profiles need explicit per-host distribution and receipts before enabling. + if (!['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) throw Error('Operation not integrated with private host transport'); + const host = catalogHost(retained, m.host); + const current = generation; + void wire.publish(m, host.request.host).catch(() => { if (!closed && current === generation) disconnected?.(1008); }); + } + return { + ready: wire.ready, + get socket() { return wire.socket; }, + send, + // At-rest envelope is local-only. Fresh wraps preserve its exact inner ID/body. + sendEnvelope(envelope: Envelope) { send(open(envelope, secret)); }, + reconnect() { + if (closed) throw Error('UI closed'); + const previous = wire; + wire = dial(); previous.close(); + void wire.ready.then(() => recovered?.()).catch(() => {}); // disconnect callback retains UNKNOWN. + }, + close() { closed = true; ++generation; wire.close(); }, + }; + }; +} diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index d73e96239a..3bbb214ee9 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -8,6 +8,7 @@ import { readAgentSecret } from './key-input.ts'; import { conversationInput } from './conversation-input.ts'; import { localSetup } from './local-setup.ts'; import { enrollmentInput } from './enrollment-input.ts'; +import { verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; import { Profiles, profile, profileRevision, type Profile } from './profiles.ts'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; @@ -26,9 +27,10 @@ const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; const [command, ...args] = process.argv.slice(2); const help = `Beehive — isolated development preview (loopback relay only) - identity Create a NEW local owner identity + identity Disabled: use your existing owner signer setup Offline host pairing/owner approval/import setup LEGACY loopback diagnostic setup (owner key copied) + catalog Retain verified public host registrations presets Local process-free preset discovery/setup guidance local-setup Bindings; new/reuse/hidden standby/restore identity migrate-slots Explicit stopped upgrade, preserves journal @@ -47,11 +49,7 @@ assignment-export accepts agent public key after filename when several slots exi Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC or production relay support.`; async function main() { if (command === 'identity') { - const dir = resolve(text(args[0])); - mkdirSync(dir,{ mode: 0o700 }); - const secret = newKey(); - writePrivate(join(dir,'identity.json'),{ secret }); - console.log(`Owner public key: ${publicKey(secret)}\nIdentity file: ${join(dir,'identity.json')}`); + throw Error('Standalone Beehive uses your existing owner identity through explicit secure input. Creating a parallel controller identity or persisting a plaintext owner key is disabled.'); } else if (command === 'presets') { showPresets(); } else if (command === 'setup') { @@ -260,8 +258,18 @@ async function main() { if (!controller.signal.aborted || error !== controller.signal.reason) throw error; // Transport ready rejection caused by intentional startup cancellation. } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); } + } else if (command === 'catalog') { + if (args.length < 2) throw Error('Catalog requires private registration files'); + const registrations = args.slice(1).map(file => readPrivate(resolve(text(file)))); + const first = object(object(registrations[0]).request); + const catalog = verifyHostCatalog({ version: 1, owner: first.owner, relay: first.relay, registrations }, text(first.owner), text(first.relay)); + writePrivate(resolve(text(args[0])), catalog, true); + console.log(`Retained ${catalog.registrations.length} verified host registrations. Public keys only; admission pending. Labels are not authority.`); } else if (command === 'tui') { - const secret = text(object(readPrivate(resolve(text(args[0])))).secret); + const identity = object(readPrivate(resolve(text(args[0])))); + const catalog: HostCatalog | undefined = 'registrations' in identity ? verifyHostCatalog(identity, text(identity.owner), text(args[1])) : undefined; + const secret = catalog ? await readAgentSecret('Owner') : text(identity.secret); + if (catalog && publicKey(secret) !== catalog.owner) throw Error('Wrong catalog owner signer'); const inventory = new Map(); const profiles = new Profiles(); const client = managementClient(join(dirname(resolve(text(args[0]))), 'management-intents'),text(args[1]),secret,m => { @@ -269,12 +277,13 @@ async function main() { if (m.type === 'inventory') { const key = JSON.stringify([m.host, m.agent]); const prior = inventory.get(key); + if (!prior && inventory.size >= 1000) return; if (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt)) inventory.set(key,m); } }, () => { console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.'); for (const operation of client.status()) console.log(`${operationLabel(operation.request)}: ${operation.state} | ${operation.result ?? operation.publication}`); - }); + }, catalog ? { catalog } : undefined); try { await client.ready; } catch (error) { client.close(); throw error; } const ui = createInterface({ input: stdin, output: stdout }); console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: binding , configurations, config-new, config-select , config-rename, config-remove , profiles, profile-new, profile-edit , apply , operations, reconcile, retry , hosts, agents, select , show, save, start, restart, stop, move, quit. Closing this UI does not stop hosts.'); @@ -300,7 +309,13 @@ async function main() { } continue; } - if (line === 'hosts') { [...inventory.values()].forEach((m, index) => console.log(`${index + 1}. ${m.host} | agent ${m.agent} | assigned ${m.body.assignedHost} | ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'recent host report'}`)); continue; } + if (line === 'hosts') { + if (catalog) for (const r of catalog.registrations) { + const expired = Date.now() >= r.expires * 1000; + console.log(`${r.request.label} | host ${r.request.host} | ${expired ? 'REGISTRATION EXPIRED / UNKNOWN' : [...inventory.values()].some(m => m.host === r.request.host) ? 'registered infrastructure' : 'UNREACHABLE / UNKNOWN (no host report)'}`); + } + [...inventory.values()].forEach((m, index) => console.log(`${index + 1}. ${m.host} | agent ${m.agent} | assigned ${m.body.assignedHost} | ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 || (catalog && !catalog.registrations.some(r => r.request.host === m.host && Date.now() < r.expires * 1000)) ? 'STALE/UNKNOWN' : 'recent host report'}`)); continue; + } if (line === 'agents') { const agents = new Set([...inventory.values()].map(m => m.agent)); for (const agent of agents) console.log(`Agent ${agent}: ${[...inventory.values()].filter(m => m.agent === agent).map(m => `${m.host} (reports assigned ${m.body.assignedHost}, ${m.body.phase})`).join('; ')}`); @@ -336,6 +351,7 @@ async function main() { const current = inventory.get(selected); if (!current) { console.log('Select an advertised host first.'); continue; } if (line === 'show') { + if (catalog && (Date.now() - Number(current.body.observedAt) > 6000 || !catalog.registrations.some(r => r.request.host === current.host && Date.now() < r.expires * 1000))) console.log('STALE/UNKNOWN: the following is a historical host report, not current actual state.'); const next = current.body.selectedNext as any, actual = current.body.actualRun as any; const label = (s: any) => `${s?.configuration ? `${s.configuration.name} @ ${s.configuration.revision}` : 'default (legacy/unversioned)'} | behavior ${s?.behavior ? `${s.behavior.name} @ ${s.behavior.revision.slice(0, 12)}` : 'upstream default'}`; console.log(`Agent ${current.agent} | host ${current.host} | assigned ${current.body.assignedHost}\nCurrent: ${actual ? label(actual.selection) : 'no actual run'}\nSelected next: ${label(next)} (explicit Restart to apply)\n${JSON.stringify(current,null,2)}`); continue; } diff --git a/beehive/src/credential-store.ts b/beehive/src/credential-store.ts new file mode 100644 index 0000000000..9ff74a134d --- /dev/null +++ b/beehive/src/credential-store.ts @@ -0,0 +1,47 @@ +import { publicKey } from './protocol.ts'; + +/** Beehive namespace only. Never query Buzz Desktop's service or legacy entries. */ +export const credentialService = 'beehive'; +export type CredentialReference = { service: 'beehive'; role: 'host' | 'agent' | 'owner'; publicKey: string }; +/** Native implementations must serialize cross-process mutations and read back + * from the OS, not a cache. Missing differs from locked/denied/unavailable (throw). + * Tests inject an explicitly isolated backend; they do not validate OS storage. + */ +export interface CredentialBackend { + read(reference: CredentialReference): string | null; + create(reference: CredentialReference, secret: string): void; + remove(reference: CredentialReference): void; +} +/** No vetted TS native bridge is present. No prompting or plaintext fallback. */ +export const systemCredentials: CredentialBackend = { + read() { throw unavailable(); }, create() { throw unavailable(); }, remove() { throw unavailable(); }, +}; +function unavailable() { return Error('Beehive OS credential store unavailable: a reviewed native Keychain/Secret Service/Credential Manager bridge is required. No plaintext fallback; existing keys are not migrated or regenerated.'); } +/** Public references never contain a private identity key. */ +export function credentialReference(role: CredentialReference['role'], key: string): CredentialReference { + if (!['host', 'agent', 'owner'].includes(role) || !/^[0-9a-f]{64}$/.test(key)) throw Error('Invalid credential reference'); + return { service: credentialService, role, publicKey: key }; +} +/** Missing/removed keys must be explicitly imported, never silently recreated. */ +export function readCredential(reference: CredentialReference, backend: CredentialBackend = systemCredentials): string { + if (reference.service !== credentialService) throw Error('Wrong credential namespace'); + credentialReference(reference.role, reference.publicKey); + const secret = backend.read(reference); + if (secret === null) throw Error('Local identity key missing or intentionally removed; explicitly import the matching key'); + if (publicKey(secret) !== reference.publicKey) throw Error('Credential identity mismatch'); + return secret; +} +/** Explicit creation/import only; verify the backend round trip before public state. */ +export function createCredential(role: CredentialReference['role'], secret: string, backend: CredentialBackend = systemCredentials): CredentialReference { + const reference = credentialReference(role, publicKey(secret)); + if (backend.read(reference) !== null) throw Error('Credential already exists; refusing replacement'); + backend.create(reference, secret); + if (readCredential(reference, backend) !== secret) throw Error('Credential verification failed'); + return reference; +} +/** Verified absence precedes changing public-only metadata. Failures propagate. */ +export function removeCredential(reference: CredentialReference, backend: CredentialBackend = systemCredentials): void { + readCredential(reference, backend); + backend.remove(reference); + if (backend.read(reference) !== null) throw Error('Credential removal not verified'); +} diff --git a/beehive/src/host-catalog.ts b/beehive/src/host-catalog.ts new file mode 100644 index 0000000000..d35506d379 --- /dev/null +++ b/beehive/src/host-catalog.ts @@ -0,0 +1,66 @@ +import { fields, object, parseMessage, type Message } from './protocol.ts'; +import { hostPairing, verifyHostRegistration, type HostRegistration } from './host-registration.ts'; +import type { AuthenticatedMessage } from './nostr-codec.ts'; + +/** Public-key-only retained owner catalog. Registration is not admission or placement. */ +export type HostCatalog = { version: 1; owner: string; relay: string; registrations: HostRegistration[] }; +/** Reverify persisted input, including signatures, scope and expiry, on every use. */ +export function verifyHostCatalog(value: unknown, owner: string, relay: string, now = Math.floor(Date.now() / 1000)): HostCatalog { + const c = object(value); + fields(c, ['version', 'owner', 'relay', 'registrations']); + if (c.version !== 1 || c.owner !== owner || c.relay !== relay || !Array.isArray(c.registrations) || c.registrations.length > 256) throw Error('Invalid owner host catalog scope'); + const keys = new Set(); + const registrations = c.registrations.map(value => { + const request = hostPairing(object(value).request); + if (request.owner !== owner || request.relay !== relay) throw Error('Host catalog registration scope mismatch'); + const registration = verifyHostRegistration(value, request, now); + if (keys.has(request.host)) throw Error('Duplicate host catalog identity'); + keys.add(request.host); + return registration; + }); + return { version: 1, owner, relay, registrations }; +} +/** Host PUBLIC KEY is the route. Duplicate display labels never confer authority. */ +export function catalogHost(catalog: HostCatalog, host: string, now = Math.floor(Date.now() / 1000)): HostRegistration { + const current = verifyHostCatalog(catalog, catalog.owner, catalog.relay, now); + const registration = current.registrations.find(r => r.request.host === host); + if (!registration) throw Error('Host is not registered in owner catalog'); + return registration; +} +/** Only the exact registered infrastructure signer may supply this host's reports. */ +export function catalogResponse(catalog: HostCatalog, input: AuthenticatedMessage, now = Math.floor(Date.now() / 1000)): Message { + const m = parseMessage(input.message); + const registration = catalogHost(catalog, m.host, now); + if (input.sender !== registration.request.host || !['inventory', 'receipt'].includes(m.type)) throw Error('Untrusted host response'); + if (m.type === 'inventory' && (typeof m.body.observedAt !== 'number' || !Number.isSafeInteger(m.body.observedAt) || m.body.observedAt < 0 || m.body.observedAt > now * 1000 + 1999)) throw Error('Invalid host observation time'); + return m; +} +/** Existing inventory consumer with registration and freshness fences, not a second client. */ +export class HostInventory { + readonly #catalog: HostCatalog; + readonly #reports = new Map(); + constructor(catalog: HostCatalog) { this.#catalog = structuredClone(verifyHostCatalog(catalog, catalog.owner, catalog.relay)); } + receive(input: AuthenticatedMessage, now = Date.now()): Message { + const m = catalogResponse(this.#catalog, input, Math.floor(now / 1000)); + if (m.type === 'inventory') { + const observed = m.body.observedAt; + if (typeof observed !== 'number' || !Number.isSafeInteger(observed) || observed < 0 || observed > now + 1000) throw Error('Invalid host observation time'); + const key = JSON.stringify([m.host, m.agent]); + const previous = this.#reports.get(key); + if (!previous || observed > Number(previous.body.observedAt)) this.#reports.set(key, structuredClone(m)); + } + return m; + } + /** Missing/stale reports never mean stopped. Expired registration never means trusted. */ + rows(now = Date.now()) { + return this.#catalog.registrations.flatMap(r => { + const reports = [...this.#reports.values()].filter(m => m.host === r.request.host); + const trusted = now < r.expires * 1000; + return (reports.length ? reports : [undefined]).map(report => ({ + host: r.request.host, label: r.request.label, agent: report?.agent, + status: !trusted ? 'registration-expired' : !report || now - Number(report.body.observedAt) > 6000 ? 'unreachable/unknown' : 'recent-host-report', + report: trusted && report ? structuredClone(report) : undefined, + })); + }); + } +} diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts index 0076558f7a..c3828a31ff 100644 --- a/beehive/src/host-identity.ts +++ b/beehive/src/host-identity.ts @@ -1,3 +1,4 @@ +import { createCredential, credentialReference, readCredential, systemCredentials, type CredentialBackend } from './credential-store.ts'; import { existsSync, mkdirSync, rmdirSync } from 'node:fs'; import { join } from 'node:path'; import { fields, newKey, object, publicKey } from './protocol.ts'; @@ -5,7 +6,7 @@ import { readPrivate, writePrivate } from './storage.ts'; import { hostPairing, verifyHostRegistration, type HostPairing, type HostRegistration } from './host-registration.ts'; /** Infrastructure registration is separate from relay admission and agent placement. */ -export type HostIdentity = { version: 2; pairing: HostPairing; secret: string; registration: HostRegistration | null }; +export type HostIdentity = { version: 3; pairing: HostPairing; secret: string; registration: HostRegistration | null }; function locked(directory: string, operation: () => T): T { mkdirSync(directory, { recursive: true, mode: 0o700 }); const lock = join(directory, 'host.lock'); @@ -13,31 +14,35 @@ function locked(directory: string, operation: () => T): T { try { return operation(); } finally { rmdirSync(lock); } } /** Offline bootstrap; neither requires admission nor copies the owner's secret. */ -export function bootstrapHostIdentity(directory: string, label: string, owner: string, relay: string): HostIdentity { +export function bootstrapHostIdentity(directory: string, label: string, owner: string, relay: string, credentials: CredentialBackend = systemCredentials): HostIdentity { return locked(directory, () => { if (existsSync(join(directory, 'setup.json'))) throw Error('Legacy installation retained; use a clean directory'); + if (existsSync(join(directory, 'host-identity.json'))) throw Error('Host identity already exists; no replacement or automatic migration'); const secret = newKey(); const pairing = hostPairing({ version: 1, purpose: 'beehive-host-registration', host: publicKey(secret), owner, label, relay, nonce: newKey() }); - const identity: HostIdentity = { version: 2, pairing, secret, registration: null }; - writePrivate(join(directory, 'host-identity.json'), identity, true); + const key = createCredential('host', secret, credentials); + const identity: HostIdentity = { version: 3, pairing, secret, registration: null }; + writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing, key, registration: null }, true); return identity; }); } /** Reject legacy broad OA records rather than silently migrating their authority. */ -export function readHostIdentity(directory: string): HostIdentity { +export function readHostIdentity(directory: string, credentials: CredentialBackend = systemCredentials): HostIdentity { const value = object(readPrivate(join(directory, 'host-identity.json'))); - fields(value, ['version', 'pairing', 'secret', 'registration']); - if (value.version !== 2 || typeof value.secret !== 'string' || !/^[0-9a-f]{64}$/.test(value.secret)) throw Error('Invalid host identity; legacy OA is not infrastructure enrollment'); + fields(value, ['version', 'pairing', 'key', 'registration']); + if (value.version !== 3) throw Error('Legacy identity retained; explicit consent required for migration'); const pairing = hostPairing(value.pairing); - if (publicKey(value.secret) !== pairing.host) throw Error('Host key differs from pairing request'); - return { version: 2, pairing, secret: value.secret, registration: value.registration as HostRegistration | null }; + const key = credentialReference('host', pairing.host); + if (JSON.stringify(value.key) !== JSON.stringify(key)) throw Error('Invalid host credential reference'); + const secret = readCredential(key, credentials); + return { version: 3, pairing, secret, registration: value.registration as HostRegistration | null }; } /** Atomically import only an owner registration matching the exact retained request. */ -export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000)): void { +export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000), credentials: CredentialBackend = systemCredentials): void { locked(directory, () => { - const identity = readHostIdentity(directory); + const identity = readHostIdentity(directory, credentials); identity.registration = verifyHostRegistration(registration, identity.pairing, now); - writePrivate(join(directory, 'host-identity.json'), identity); + writePrivate(join(directory, 'host-identity.json'), { version: 3, pairing: identity.pairing, key: credentialReference('host', identity.pairing.host), registration: identity.registration }); }); } /** Valid registration is NOT relay admission. Remains no-network until a narrow contract exists. */ diff --git a/beehive/src/host-transport.ts b/beehive/src/host-transport.ts new file mode 100644 index 0000000000..6e4e6ab365 --- /dev/null +++ b/beehive/src/host-transport.ts @@ -0,0 +1,38 @@ +import { connectNostr } from './nostr-client.ts'; +import { verifyHostRegistration, type HostRegistration } from './host-registration.ts'; +import { productionAdmission, type ScopedRelayAdmission } from './relay-admission.ts'; +import { publicKey, type Message } from './protocol.ts'; + +/** Private infrastructure transport for the existing host executor. No agent grant + * is manufactured here; its retained per-agent journal remains the authority owner. + */ +export function privateHostTransport(registration: HostRegistration, secret: string, admission: ScopedRelayAdmission = productionAdmission) { + const retained = structuredClone(verifyHostRegistration(registration, registration.request)); + if (publicKey(secret) !== retained.request.host) throw Error('Wrong infrastructure signer'); + return { + binding: { host: retained.request.host, owner: retained.request.owner }, + validate(url: string) { + verifyHostRegistration(retained, retained.request); + if (url !== retained.request.relay) throw Error('Wrong host relay'); + admission.admit({ relay: url, publicKey: retained.request.host, transport: 'nip42-nip59', ownerDelegation: false }); + }, + connect(url: string, _legacyOwnerSecret: string, receive: (m: Message) => void, _recovered?: () => void) { + this.validate(url); + const wire = connectNostr(url, Buffer.from(secret, 'hex'), undefined, input => { + try { verifyHostRegistration(retained, retained.request); } catch { return; } + const m = input.message; + if (input.sender !== retained.request.owner || m.host !== retained.request.host || !['inspect', 'save', 'start', 'restart', 'stop'].includes(m.type)) return; + receive(m); + }, () => {}); // Existing durable host outbox survives; no automatic launch retry. + return { + ready: wire.ready, + close: () => wire.close(), + send(m: Message) { + verifyHostRegistration(retained, retained.request); + if (m.host !== retained.request.host || !['inventory', 'receipt'].includes(m.type)) throw Error('Invalid private host report'); + void wire.publish(m, retained.request.owner).catch(() => {}); // Host outbox/heartbeat retains retry state. + }, + }; + }, + }; +} diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 9ad5db6ca2..c3f635d78c 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -532,12 +532,12 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } /** One installation owns every slot, lock and management transport. */ -export async function host(directory: string, url: string, signal?: AbortSignal) { +export async function host(directory: string, url: string, signal?: AbortSignal, transport?: { binding: { host: string; owner: string }; validate(url: string): void; connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void): { ready: Promise; send(m: Message): void; close(): void } }) { signal?.throwIfAborted(); - validateRelayURL(url); + (transport?.validate ?? validateRelayURL)(url); const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); // Never infer ownership from a recovered PID. - let client: ReturnType | undefined; + let client: { ready: Promise; send(m: Message): void; close(): void } | undefined; let initialized = false; const slots = new Map>(); const profiles = new Profiles(); @@ -557,6 +557,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal) try { const entries = installationSlots(directory); for (const entry of entries) { + if (transport && (entry.setup.host !== transport.binding.host || publicKey(entry.setup.ownerSecret) !== transport.binding.owner)) throw Error('Private transport differs from retained host/agent authority'); slots.set(entry.agent, slot(entry.setup, entry.path, entry.agent, (id) => { const current = installationSlots(directory).find(e => e.agent === entry.agent); if (!current) throw Error('Key removed'); @@ -570,7 +571,7 @@ export async function host(directory: string, url: string, signal?: AbortSignal) // Every slot is hydrated before dialing. Initial WS history may arrive in the // same event-loop turn as open, before the ready promise continuation. initialized = true; - client = connect(url, setup.ownerSecret, m => { + client = (transport ? transport.connect.bind(transport) : connect)(url, setup.ownerSecret, m => { if (!initialized) return; profiles.receive(m); if (m.host !== setup.host) return; diff --git a/beehive/src/intents.ts b/beehive/src/intents.ts index db384dd788..9aed0805c9 100644 --- a/beehive/src/intents.ts +++ b/beehive/src/intents.ts @@ -1,4 +1,7 @@ import { profile } from './profiles.ts'; +import { catalogTransport } from './catalog-transport.ts'; +import { verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; +import type { ScopedRelayAdmission } from './relay-admission.ts'; import { existsSync, mkdirSync, readdirSync, statSync, lstatSync } from 'node:fs'; import { join } from 'node:path'; import { connect, validateRelayURL } from './client.ts'; @@ -16,8 +19,9 @@ function matches(request: Message, receipt: Message) { * Immutable intent files precede all network effects. Receipt files are separate * so another reader's old publication observation cannot erase a terminal result. */ -export function managementClient(root: string, url: string, secret: string, receive: (m: Message) => void, changed: () => void = () => {}) { - validateRelayURL(url); +export function managementClient(root: string, url: string, secret: string, receive: (m: Message) => void, changed: () => void = () => {}, privateHosts?: { catalog: HostCatalog; admission?: ScopedRelayAdmission }) { + if (privateHosts) verifyHostCatalog(privateHosts.catalog, publicKey(secret), url); + else validateRelayURL(url); const scope = digest(JSON.stringify([url, publicKey(secret)])).toString('hex'); const dir = join(root, scope); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -64,7 +68,8 @@ export function managementClient(root: string, url: string, secret: string, rece } changed(); } - const transport = connect(url, secret, m => { + const connectTransport = privateHosts ? catalogTransport(privateHosts.catalog, privateHosts.admission) : connect; + const transport = connectTransport(url, secret, m => { if (m.type === 'receipt') { const intent = intents.get(String(m.body.operation)); if (intent && !intent.receipt && matches(intent.request, m)) { @@ -99,6 +104,7 @@ export function managementClient(root: string, url: string, secret: string, rece submit(request: Message) { if (closed) throw Error('UI closed'); if (!['profile','save','start','restart','stop','move'].includes(request.type)) throw Error('Invalid operation'); + if (privateHosts && !['save', 'start', 'restart', 'stop'].includes(request.type)) throw Error('Private profile distribution and Move authority are not integrated; no operation prepared'); if (request.type === 'profile') { profile(request.body); if (request.host !== 'profiles' || request.agent !== 'profiles' || request.revision !== 0) throw Error('Invalid profile publication'); diff --git a/beehive/src/nostr-client.ts b/beehive/src/nostr-client.ts index 98268d49b0..b3357e95d7 100644 --- a/beehive/src/nostr-client.ts +++ b/beehive/src/nostr-client.ts @@ -67,6 +67,7 @@ export function connectNostr(url: string, secret: Uint8Array, authTag: string[] }); return { ready, + get socket() { return socket; }, /** Resolves on relay acceptance, never on host operation completion. */ publish(message: Message, recipient: string): Promise { if (!subscribed || ended) return Promise.reject(Error('Nostr not subscribed; result unknown')); diff --git a/beehive/src/relay-admission.ts b/beehive/src/relay-admission.ts new file mode 100644 index 0000000000..9a46421f28 --- /dev/null +++ b/beehive/src/relay-admission.ts @@ -0,0 +1,10 @@ +/** Admission proof supplied by a reviewed integration, never loaded from user JSON. + * Registration and ordinary relay membership do not establish this contract. + */ +export type ScopedRelayAdmission = { + admit(scope: { relay: string; publicKey: string; transport: 'nip42-nip59'; ownerDelegation: false }): void; +}; +/** No production admission profile is established. Do not dial or send NIP-OA. */ +export const productionAdmission: ScopedRelayAdmission = { + admit() { throw Error('Host relay admission pending: reviewed scoped infrastructure permissions required; NIP-OA prohibited'); }, +}; diff --git a/beehive/test/catalog-tui.test.ts b/beehive/test/catalog-tui.test.ts new file mode 100644 index 0000000000..0def3a9304 --- /dev/null +++ b/beehive/test/catalog-tui.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { newKey, publicKey, message } from '../src/protocol.ts'; +import { hostPairing, registerHost } from '../src/host-registration.ts'; +import { verifyHostCatalog, catalogResponse, HostInventory } from '../src/host-catalog.ts'; +import { privateHostTransport } from '../src/host-transport.ts'; +import { host } from '../src/host.ts'; +import { writePrivate } from '../src/storage.ts'; +import { provisionSetup } from './provision.ts'; +import { nostrFixture } from './nostr-fixture.ts'; +import { connectNostr } from '../src/nostr-client.ts'; +import { productionAdmission, type ScopedRelayAdmission } from '../src/relay-admission.ts'; + +function registration(owner: string, key: string, relay: string, label = 'same label') { + return registerHost(hostPairing({ version: 1, purpose: 'beehive-host-registration', host: publicKey(key), owner: publicKey(owner), label, relay, nonce: newKey() }), owner, Math.floor(Date.now() / 1000) + 600); +} + +test('catalog verifies owner/signature/relay/expiry; labels cannot spoof identity and missing is not stopped', () => { + const owner = newKey(), a = newKey(), b = newKey(), relay = 'wss://example.invalid'; + const one = registration(owner, a, relay), two = registration(owner, b, relay); + const value = { version: 1, owner: publicKey(owner), relay, registrations: [one, two] }; + const catalog = verifyHostCatalog(value, value.owner, relay); + assert.throws(() => verifyHostCatalog(value, publicKey(newKey()), relay)); + assert.throws(() => verifyHostCatalog(value, value.owner, 'wss://wrong.invalid')); + assert.throws(() => verifyHostCatalog({ ...value, registrations: [{ ...one, signature: '0'.repeat(128) }] }, value.owner, relay)); + assert.throws(() => verifyHostCatalog(value, value.owner, relay, one.expires)); + const inventory = message('inventory', publicKey(a), 'agent', 0, { observedAt: Date.now(), phase: 'stopped' }); + assert.throws(() => catalogResponse(catalog, { sender: publicKey(b), message: inventory })); + assert.throws(() => catalogResponse(catalog, { sender: publicKey(a), message: { ...inventory, host: 'same label' } })); + const view = new HostInventory(catalog); + assert.deepEqual(view.rows().map(r => r.status), ['unreachable/unknown', 'unreachable/unknown']); + view.receive({ sender: publicKey(a), message: inventory }); + assert.equal(view.rows()[0]!.status, 'recent-host-report'); + assert.equal(view.rows()[0]!.report!.body.phase, 'stopped'); + assert.equal(view.rows()[1]!.status, 'unreachable/unknown'); + assert.equal(view.rows(Date.now() + 7000)[0]!.status, 'unreachable/unknown'); + assert.equal(view.rows(one.expires * 1000)[0]!.report, undefined); + assert.throws(() => productionAdmission.admit({ relay, publicKey: publicKey(a), transport: 'nip42-nip59', ownerDelegation: false }), /pending/); +}); + +test('actual owner TUI private inventory and Start/Stop use existing host executor with independent no-OA fixture admission', { timeout: 30000 }, async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-catalog-tui-'))); + const owner = newKey(), a = newKey(), b = newKey(), agent = newKey(); + const members = new Set([owner, a, b].map(publicKey)); + const relay = await nostrFixture(publicKey(owner), members); + const one = registration(owner, a, relay.url), two = registration(owner, b, relay.url); + const catalog = verifyHostCatalog({ version: 1, owner: publicKey(owner), relay: relay.url, registrations: [one, two] }, publicKey(owner), relay.url); + const admission: ScopedRelayAdmission = { admit(scope) { assert.equal(scope.relay, relay.url); assert.equal(scope.ownerDelegation, false); assert.ok(members.has(scope.publicKey)); } }; + const directory = join(root, 'host'); mkdirSync(directory); + provisionSetup(join(directory, 'setup.json'), { host: publicKey(a), ownerSecret: owner, agentSecret: agent, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: root, mode: 'fixture' }); + const running = await host(directory, relay.url, undefined, privateHostTransport(one, a, admission)); + const foreignMessages: unknown[] = []; + const foreign = connectNostr(relay.url, Buffer.from(b, 'hex'), undefined, m => foreignMessages.push(m), () => {}); + await foreign.ready; + const path = join(root, 'catalog.json'); writePrivate(path, catalog); + // Replace only the admission dependency, not CLI, catalog, transport, journal or executor. + const loader = join(root, 'admission-loader.mjs'); + writeFileSync(loader, `import { registerHooks } from 'node:module'; registerHooks({load(url,ctx,next){const r=next(url,ctx); if(!url.endsWith('/src/relay-admission.ts'))return r;return {...r,source:'export const productionAdmission = { admit(s) { if(s.relay !== '+${JSON.stringify(JSON.stringify(relay.url))}+' || s.publicKey !== '+${JSON.stringify(JSON.stringify(publicKey(owner)))}+' || s.ownerDelegation !== false) throw Error("fixture admission scope"); } };'};}});`); + const env: NodeJS.ProcessEnv = { ...process.env }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; + const child = spawn(process.execPath, ['--import', loader, 'src/cli.ts', 'tui', path, relay.url], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; child.stdout.on('data', b => output += b.toString()); child.stderr.on('data', b => output += b.toString()); + const exit = new Promise((done, reject) => { child.on('exit', done); child.on('error', reject); }); + async function wait(predicate: () => boolean) { for (let n = 0; n < 400; n++) { if (predicate()) return; if (child.exitCode !== null) throw Error(output); await delay(20); } throw Error(`TUI observation timeout: ${output}`); } + async function command(line: string) { const start = output.length; child.stdin.write(`${line}\n`); await wait(() => output.slice(start).includes('beehive> ')); return output.slice(start); } + try { + await wait(() => output.includes('Owner private key')); + child.stdin.write(`${owner}\n`); + await wait(() => output.includes('beehive> ')); + let rows = await command('hosts'); + assert.match(rows, /UNREACHABLE \/ UNKNOWN/); + assert.ok(rows.includes(publicKey(a))); assert.ok(rows.includes(publicKey(b))); + assert.match(rows, /recent host report/); + await command(`select ${publicKey(a)}`); + await command('start'); + await wait(() => output.includes('completed | accepted')); + let shown = ''; + for (let n = 0; n < 80; n++) { shown = await command('show'); if (shown.includes('"phase": "running"')) break; await delay(25); } + assert.match(shown, /"phase": "running"/); + assert.ok(shown.includes(publicKey(agent))); + const stopAt = output.length; + await command('stop'); + await wait(() => output.slice(stopAt).includes('accepted')); + for (let n = 0; n < 80; n++) { shown = await command('show'); if (shown.includes('"phase": "stopped"')) break; await delay(25); } + assert.match(shown, /"phase": "stopped"/); + const beforeReplay = readFileSync(join(directory, 'journal.json'), 'utf8'); + await foreign.publish(message('start', publicKey(a), publicKey(agent), 2), publicKey(a)); + await foreign.publish(message('inventory', publicKey(a), publicKey(agent), 999, { phase: 'FORGED', observedAt: Date.now() + 500 }), publicKey(owner)); + const ownerWire = connectNostr(relay.url, Buffer.from(owner, 'hex'), undefined, () => {}, () => {}); + await ownerWire.ready; + try { + const journal = JSON.parse(beforeReplay); + // Rewrap the exact original durable Start after Stop. Relay ACK is not another execution. + const intentDirectory = join(root, 'management-intents'); + const { readdirSync } = await import('node:fs'); + const { open } = await import('../src/protocol.ts'); + const scope = join(intentDirectory, readdirSync(intentDirectory)[0]!); + const intent = readdirSync(scope).filter(n => n.endsWith('.intent')).map(n => JSON.parse(readFileSync(join(scope, n), 'utf8'))).map(v => open(v.envelope, owner)).find(m => m.type === 'start')!; + await ownerWire.publish(intent, publicKey(a)); + await command('reconcile'); + await delay(100); + shown = await command('show'); + assert.ok(!shown.includes('FORGED')); + assert.match(shown, /"phase": "stopped"/); + assert.equal(readFileSync(join(directory, 'journal.json'), 'utf8'), beforeReplay); + assert.equal(Object.keys(journal.operations).length, 2); + } finally { ownerWire.close(); } + assert.equal(foreignMessages.length, 0, 'another admitted host cannot read owner inventory or commands'); + assert.ok(relay.history.every(e => e.kind === 1059 && e.tags.length === 1)); + assert.ok(!output.includes(owner)); assert.ok(!output.includes(a)); assert.ok(!output.includes(agent)); + child.stdin.write('quit\n'); assert.equal(await exit, 0, output); + const state = JSON.parse(readFileSync(join(directory, 'journal.json'), 'utf8')); + assert.equal(state.phase, 'stopped'); assert.equal(state.revision, 2); + } finally { + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exit; } + foreign.close(); await running.close(); await relay.close(); rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/beehive/test/credential-fixture.ts b/beehive/test/credential-fixture.ts new file mode 100644 index 0000000000..6810d31c34 --- /dev/null +++ b/beehive/test/credential-fixture.ts @@ -0,0 +1,11 @@ +import type { CredentialBackend, CredentialReference } from '../src/credential-store.ts'; +/** Explicit in-memory test backend, NOT an OS credential-store validation. */ +export function memoryCredentials(): CredentialBackend { + const values = new Map(); + const id = (r: CredentialReference) => JSON.stringify(r); + return { + read: r => values.get(id(r)) ?? null, + create(r, secret) { if (values.has(id(r))) throw Error('Already exists'); values.set(id(r), secret); }, + remove(r) { values.delete(id(r)); }, + }; +} diff --git a/beehive/test/credential-store.test.ts b/beehive/test/credential-store.test.ts new file mode 100644 index 0000000000..8796d54e9a --- /dev/null +++ b/beehive/test/credential-store.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createCredential, readCredential, removeCredential, systemCredentials, type CredentialBackend } from '../src/credential-store.ts'; +import { bootstrapHostIdentity, readHostIdentity } from '../src/host-identity.ts'; +import { newKey } from '../src/protocol.ts'; +import { memoryCredentials } from './credential-fixture.ts'; + +test('explicit isolated credential lifecycle; absence, locked/denied and bad roundtrip fail closed', () => { + const backend = memoryCredentials(), secret = newKey(); + const reference = createCredential('agent', secret, backend); + assert.equal(readCredential(reference, backend), secret); + assert.throws(() => createCredential('agent', secret, backend), /exists/); + removeCredential(reference, backend); + assert.throws(() => readCredential(reference, backend), /missing or intentionally removed/); + for (const reason of ['locked', 'denied', 'unavailable']) { + const failed: CredentialBackend = { read() { throw Error(reason); }, create() { assert.fail('must not write after unavailable read'); }, remove() { assert.fail('must not delete after unavailable read'); } }; + assert.throws(() => createCredential('host', secret, failed), new RegExp(reason)); + assert.throws(() => removeCredential(reference, failed), new RegExp(reason)); + } + const bad: CredentialBackend = { read: () => null, create() {}, remove() {} }; + assert.throws(() => createCredential('host', secret, bad), /missing/); + assert.throws(() => createCredential('owner', secret, systemCredentials), /No plaintext fallback/); + const again = createCredential('agent', secret, backend); + const noDelete = { ...backend, remove() {} }; + assert.throws(() => removeCredential(again, noDelete), /not verified/); +}); + +test('host state persists public references only; removed keys never regenerate; default does not touch OS or write plaintext', () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-key-reference-')); + try { + const backend = memoryCredentials(); + const owner = 'a'.repeat(64); + assert.throws(() => bootstrapHostIdentity(join(root, 'production'), 'host', owner, 'wss://example.invalid'), /OS credential store unavailable/); + assert.equal(existsSync(join(root, 'production', 'host-identity.json')), false); + const directory = join(root, 'fixture'); + const identity = bootstrapHostIdentity(directory, 'host', owner, 'wss://example.invalid', backend); + const content = readFileSync(join(directory, 'host-identity.json'), 'utf8'); + assert.ok(!content.includes(identity.secret)); + const reference = JSON.parse(content).key; + removeCredential(reference, backend); + assert.throws(() => readHostIdentity(directory, backend), /missing or intentionally removed/); + assert.throws(() => bootstrapHostIdentity(directory, 'host', owner, 'wss://example.invalid', backend), /already exists/); + assert.equal(readFileSync(join(directory, 'host-identity.json'), 'utf8'), content); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/beehive/test/enrollment-input.test.ts b/beehive/test/enrollment-input.test.ts index 5a5d70f662..17efc37e02 100644 --- a/beehive/test/enrollment-input.test.ts +++ b/beehive/test/enrollment-input.test.ts @@ -1,3 +1,4 @@ +import { isolatedFileCredentials } from './isolated-file-credentials.ts'; import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; @@ -9,10 +10,10 @@ import { newKey, publicKey } from '../src/protocol.ts'; import { readHostIdentity, requireHostEnrollment } from '../src/host-identity.ts'; import { pairingFingerprint } from '../src/host-registration.ts'; -async function wizard(directory: string, steps: [string, string][]): Promise { - const env = { ...process.env }; +async function wizard(directory: string, steps: [string, string][], credentialFile: string): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile }; delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; - const child = spawn(process.execPath, [fileURLToPath(new URL('../src/cli.ts', import.meta.url)), 'setup', directory], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, ['--import', fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)), fileURLToPath(new URL('../src/cli.ts', import.meta.url)), 'setup', directory], { env, stdio: ['pipe', 'pipe', 'pipe'] }); let output = '', cursor = 0, next = 0; const timer = setTimeout(() => child.kill(), 10000); child.stdout.on('data', data => { @@ -36,25 +37,28 @@ test('actual standalone CLI private pairing, existing-owner approval and bootstr const host = join(root, 'host'), ownerContext = join(root, 'owner-context'); const request = join(root, 'request.json'), approval = join(root, 'approval.json'); const ownerSecret = newKey(); + const credentialFile = join(root, 'isolated-fixture-secrets.json'); + const credentials = isolatedFileCredentials(credentialFile); try { const created = await wizard(host, [ ['Enrollment [', 'create'], ['Host label: ', 'desktop'], ['Owner PUBLIC key (hex): ', publicKey(ownerSecret)], ['Relay URL: ', 'wss://example.invalid'], ['NEW private pairing file: ', request], - ]); - const original = readHostIdentity(host); + ], credentialFile); + const original = readHostIdentity(host, credentials); assert.ok(created.includes(pairingFingerprint(original.pairing))); assert.equal(original.registration, null); const signed = await wizard(ownerContext, [ ['Enrollment [', 'approve'], ['Private pairing file: ', request], ['[yes/no]: ', 'yes'], ['Registration expiration (Unix seconds): ', String(Math.floor(Date.now() / 1000) + 300)], ['NEW private approval file: ', approval], ['Owner private key (64 hex; hidden, never passed as an argument): ', ownerSecret], - ]); + ], credentialFile); assert.ok(signed.includes(pairingFingerprint(original.pairing))); assert.ok(!signed.includes(ownerSecret)); - const imported = await wizard(host, [['Enrollment [', 'import'], ['Private approval file: ', approval]]); + const imported = await wizard(host, [['Enrollment [', 'import'], ['Private approval file: ', approval]], credentialFile); assert.ok(imported.includes('Pending/no network')); - const enrolled = readHostIdentity(host); + const enrolled = readHostIdentity(host, credentials); assert.equal(enrolled.secret, original.secret); + assert.ok(!readFileSync(join(host, 'host-identity.json'), 'utf8').includes(original.secret)); assert.deepEqual(enrolled.pairing, original.pairing); assert.throws(() => requireHostEnrollment(enrolled), /relay admission pending/); assert.deepEqual(readdirSync(host), ['host-identity.json']); diff --git a/beehive/test/isolated-credentials-loader.ts b/beehive/test/isolated-credentials-loader.ts new file mode 100644 index 0000000000..995bc0a89d --- /dev/null +++ b/beehive/test/isolated-credentials-loader.ts @@ -0,0 +1,9 @@ +import { registerHooks } from 'node:module'; +import { fileURLToPath } from 'node:url'; +const backend = fileURLToPath(new URL('./isolated-file-credentials.ts', import.meta.url)); +// Explicit test-only module injection, never a CLI flag or production env mode. +registerHooks({ load(url, context, next) { + const result = next(url, context); + if (!url.endsWith('/src/credential-store.ts')) return result; + return { ...result, source: String(result.source).replace(/export const systemCredentials: CredentialBackend = \{[\s\S]*?\n\};/, `import { isolatedFileCredentials } from ${JSON.stringify(backend)};\nexport const systemCredentials: CredentialBackend = isolatedFileCredentials(process.env.BEEHIVE_TEST_CREDENTIAL_FILE!);`) }; +} }); diff --git a/beehive/test/isolated-file-credentials.ts b/beehive/test/isolated-file-credentials.ts new file mode 100644 index 0000000000..c71f0972eb --- /dev/null +++ b/beehive/test/isolated-file-credentials.ts @@ -0,0 +1,13 @@ +import { existsSync } from 'node:fs'; +import { readPrivate, writePrivate } from '../src/storage.ts'; +import type { CredentialBackend } from '../src/credential-store.ts'; +/** Fresh fixture secrets ONLY. Explicitly not OS storage, never imported by production. */ +export function isolatedFileCredentials(file: string): CredentialBackend { + if (!file || !file.includes('beehive-pairing-cli-')) throw Error('Missing isolated credential fixture path'); + const read = (): Record => existsSync(file) ? readPrivate(file) as Record : {}; + return { + read(r) { return read()[JSON.stringify(r)] ?? null; }, + create(r, secret) { const data = read(), id = JSON.stringify(r); if (id in data) throw Error('Existing fixture key'); data[id] = secret; writePrivate(file, data); }, + remove(r) { const data = read(); delete data[JSON.stringify(r)]; writePrivate(file, data); }, + }; +} diff --git a/beehive/test/nostr-fixture.ts b/beehive/test/nostr-fixture.ts index 319d4057de..6990ddbc8e 100644 --- a/beehive/test/nostr-fixture.ts +++ b/beehive/test/nostr-fixture.ts @@ -9,7 +9,10 @@ import { verifyHostAttestation } from '../src/host-attestation.ts'; * explicit giftwrap author/connection mismatch exception. req.rs p-gate. * No DB/token/push implementation; fixtures grant member MessagesWrite only. */ -export async function nostrFixture(owner: string) { +export async function nostrFixture(owner: string, independentMembers?: ReadonlySet) { + // api/mod.rs direct membership is distinct from ViaOwner. This is fixture + // policy only, NOT evidence that deployed membership has narrow permissions. + const members = independentMembers ? new Set(independentMembers) : undefined; const server = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: 300000 }); await new Promise(resolve => server.once('listening', resolve)); const address = server.address(); @@ -30,8 +33,10 @@ export async function nostrFixture(owner: string) { if (type === 'AUTH') { const event = value as Event; try { - if (!verifyEvent(event) || event.kind !== 22242 || Math.abs(event.created_at - now) > 900 || event.content !== '' || !event.tags.some(t => t[0] === 'relay' && t[1] === url) || !event.tags.some(t => t[0] === 'challenge' && t[1] === challenge)) throw Error('AUTH'); - if (event.pubkey !== owner) verifyHostAttestation(event.tags.find(t => t[0] === 'auth'), event.pubkey, owner, event.created_at); + if (!verifyEvent(event) || event.kind !== 22242 || Math.abs(event.created_at - now) > 60 || event.content !== '' || !event.tags.some(t => t[0] === 'relay' && t[1] === url) || !event.tags.some(t => t[0] === 'challenge' && t[1] === challenge)) throw Error('AUTH'); + if (members) { + if (!members.has(event.pubkey) || event.tags.some(t => t[0] === 'auth')) throw Error('Independent fixture membership required; OA prohibited'); + } else if (event.pubkey !== owner) verifyHostAttestation(event.tags.find(t => t[0] === 'auth'), event.pubkey, owner, event.created_at); state.pubkey = event.pubkey; socket.send(JSON.stringify(['OK', event.id, true, 'authenticated'])); } catch { socket.send(JSON.stringify(['OK', event.id, false, 'restricted: not a relay member'])); } diff --git a/beehive/test/nostr-transport.test.ts b/beehive/test/nostr-transport.test.ts index b35a6a2e44..1493002efc 100644 --- a/beehive/test/nostr-transport.test.ts +++ b/beehive/test/nostr-transport.test.ts @@ -1,3 +1,4 @@ +import { memoryCredentials } from './credential-fixture.ts'; import { registerHost, verifyHostRegistration } from '../src/host-registration.ts'; import test from 'node:test'; import { WebSocket } from 'ws'; @@ -69,19 +70,20 @@ test('durable owner-public-only pending bootstrap, genuine enrollment and instal const directory = mkdtempSync(join(tmpdir(), 'beehive-host-key-')); try { const owner = generateSecretKey(); - const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner), 'wss://example.invalid'); + const credentials = memoryCredentials(); + const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner), 'wss://example.invalid', credentials); assert.equal(identity.registration, null); assert.notEqual(identity.secret, hex(owner)); assert.throws(() => requireHostEnrollment(identity)); assert.equal(statSync(join(directory, 'host-identity.json')).mode & 0o777, 0o600); - assert.deepEqual(readHostIdentity(directory), identity); + assert.deepEqual(readHostIdentity(directory, credentials), identity); assert.throws(() => bootstrapHostIdentity(directory, 'other', identity.pairing.owner, 'wss://example.invalid')); const tag = attestHost(hex(owner), getPublicKey(Buffer.from(identity.secret, 'hex')), 'kind=1059'); mkdirSync(join(directory, 'host.lock')); - assert.throws(() => enrollHostIdentity(directory, tag)); + assert.throws(() => enrollHostIdentity(directory, tag, undefined, credentials)); rmdirSync(join(directory, 'host.lock')); - assert.throws(() => enrollHostIdentity(directory, attestHost(hex(generateSecretKey()), getPublicKey(Buffer.from(identity.secret, 'hex')), ''))); - assert.throws(() => enrollHostIdentity(directory, tag), 'broad OA must never enroll infrastructure'); + assert.throws(() => enrollHostIdentity(directory, attestHost(hex(generateSecretKey()), getPublicKey(Buffer.from(identity.secret, 'hex')), ''), undefined, credentials)); + assert.throws(() => enrollHostIdentity(directory, tag, undefined, credentials), 'broad OA must never enroll infrastructure'); const registration = registerHost(identity.pairing, hex(owner), Math.floor(Date.now() / 1000) + 60); for (const key of ['host', 'owner', 'label', 'relay', 'nonce'] as const) { const changed = { ...identity.pairing, [key]: key === 'label' ? 'other' : key === 'relay' ? 'wss://other.invalid' : hex(generateSecretKey()) }; @@ -89,9 +91,9 @@ test('durable owner-public-only pending bootstrap, genuine enrollment and instal } assert.throws(() => verifyHostRegistration({ ...registration, expires: registration.expires + 1 }, identity.pairing)); assert.throws(() => verifyHostRegistration(registration, identity.pairing, registration.expires)); - enrollHostIdentity(directory, registration); - assert.deepEqual(readHostIdentity(directory).registration, registration); - assert.throws(() => requireHostEnrollment(readHostIdentity(directory)), /relay admission pending/); + enrollHostIdentity(directory, registration, undefined, credentials); + assert.deepEqual(readHostIdentity(directory, credentials).registration, registration); + assert.throws(() => requireHostEnrollment(readHostIdentity(directory, credentials)), /relay admission pending/); } finally { rmSync(directory, { recursive: true, force: true }); } });