mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(beehive): bind private TUI lifecycle to registered host keys
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
90
beehive/PRIVATE_HOST_CATALOG_D2A1427A.md
Normal file
90
beehive/PRIVATE_HOST_CATALOG_D2A1427A.md
Normal 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.
|
||||
48
beehive/src/catalog-transport.ts
Normal file
48
beehive/src/catalog-transport.ts
Normal 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(); },
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
47
beehive/src/credential-store.ts
Normal file
47
beehive/src/credential-store.ts
Normal 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');
|
||||
}
|
||||
66
beehive/src/host-catalog.ts
Normal file
66
beehive/src/host-catalog.ts
Normal 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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
38
beehive/src/host-transport.ts
Normal file
38
beehive/src/host-transport.ts
Normal 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.
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
10
beehive/src/relay-admission.ts
Normal file
10
beehive/src/relay-admission.ts
Normal 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'); },
|
||||
};
|
||||
122
beehive/test/catalog-tui.test.ts
Normal file
122
beehive/test/catalog-tui.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
11
beehive/test/credential-fixture.ts
Normal file
11
beehive/test/credential-fixture.ts
Normal 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)); },
|
||||
};
|
||||
}
|
||||
48
beehive/test/credential-store.test.ts
Normal file
48
beehive/test/credential-store.test.ts
Normal 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 }); }
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
9
beehive/test/isolated-credentials-loader.ts
Normal file
9
beehive/test/isolated-credentials-loader.ts
Normal 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!);`) };
|
||||
} });
|
||||
13
beehive/test/isolated-file-credentials.ts
Normal file
13
beehive/test/isolated-file-credentials.ts
Normal 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); },
|
||||
};
|
||||
}
|
||||
@@ -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'])); }
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user