feat(beehive): bind private TUI lifecycle to registered host keys

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 13:00:07 -04:00
parent c00aab456d
commit 73100e5950
19 changed files with 589 additions and 47 deletions

View File

@@ -0,0 +1,90 @@
# Private host catalog continuation (implementation checkpoint, not deployable)
The existing owner TUI now accepts `tui <catalog-file> <relay>` with an explicitly
entered existing owner signer. `catalog <new-file> <registration-files...>` 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.

View File

@@ -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(); },
};
};
}

View File

@@ -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 <new-directory> Create a NEW local owner identity
identity Disabled: use your existing owner signer
setup <host-directory> Offline host pairing/owner approval/import
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
catalog <new-file> <registration-files...> Retain verified public host registrations
presets Local process-free preset discovery/setup guidance
local-setup <host-directory> Bindings; new/reuse/hidden standby/restore identity
migrate-slots <host-directory> 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<string,Message>();
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 <local-id>, configurations, config-new, config-select <name>, config-rename, config-remove <name>, profiles, profile-new, profile-edit <number>, apply <number|default>, operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, 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; }

View File

@@ -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');
}

View File

@@ -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<string>();
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<string, Message>();
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,
}));
});
}
}

View File

@@ -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<T>(directory: string, operation: () => T): T {
mkdirSync(directory, { recursive: true, mode: 0o700 });
const lock = join(directory, 'host.lock');
@@ -13,31 +14,35 @@ function locked<T>(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. */

View File

@@ -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.
},
};
},
};
}

View File

@@ -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<void>; 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<typeof connect> | undefined;
let client: { ready: Promise<void>; send(m: Message): void; close(): void } | undefined;
let initialized = false;
const slots = new Map<string, ReturnType<typeof slot>>();
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;

View File

@@ -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');

View File

@@ -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<void> {
if (!subscribed || ended) return Promise.reject(Error('Nostr not subscribed; result unknown'));

View File

@@ -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'); },
};

View File

@@ -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<number | null>((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 });
}
});

View File

@@ -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<string, string>();
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)); },
};
}

View File

@@ -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 }); }
});

View File

@@ -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<string> {
const env = { ...process.env };
async function wizard(directory: string, steps: [string, string][], credentialFile: string): Promise<string> {
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']);

View File

@@ -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!);`) };
} });

View File

@@ -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<string, string> => existsSync(file) ? readPrivate(file) as Record<string, string> : {};
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); },
};
}

View File

@@ -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<string>) {
// 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<void>(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'])); }

View File

@@ -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 }); }
});