feat(beehive): add offline standalone infrastructure registration

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 12:48:31 -04:00
parent a8e2424ee9
commit c00aab456d
9 changed files with 222 additions and 29 deletions

View File

@@ -10,6 +10,34 @@ can close while the host/runner continues. No direct-host HTTP API or controller
service exists. This package intentionally lives alongside, not inside, the
native clients: it is a separately authorized product experiment.
## Offline standalone host enrollment (partial, no network)
`node src/cli.ts setup <directory>` without an identity file opens Beehive's
own enrollment interface. No Buzz Desktop is needed. Use **create** on a fresh
host directory: enter its label, relay URL and your existing owner **public** key.
The host generates its durable independent key and exports a private pairing file.
Compare the full displayed fingerprint in your owner context.
Run the same command in your separate owner context and choose **approve**. Read
the pairing file, verify the host/key/label/relay/fingerprint, confirm registration,
choose an expiration and a new approval file, then enter your existing owner key
through hidden local input. Do not run owner approval on a host you do not trust
with that input. No new controller key is created and the owner key is not saved.
Transfer the approval privately back to the host and choose **import**. **export**
can repeat pairing-file export without replacing the retained host key.
Files must be owner-only regular files; transfer them privately and retain mode
0600. Registration is domain-separated from agent NIP-OA, binds this exact pairing
request, and grants neither agent placement nor owner account permissions.
Registration expiration is checked locally; offline verification cannot discover
remote revocation, prove a physical machine, or erase a copied key.
**This milestone stops at registered/pending/no-network.** Narrow relay admission,
private host inventory in the TUI, and Start/reply/Move/reply over the new transport
are not implemented. Broad NIP-OA cannot enroll a host. The legacy diagnostic
workflow below remains separate and stores a shared owner key; do not use it for
real host enrollment. Existing a8 OA identity files are rejected, not migrated.
## Run an isolated fixture
Requires Node >=22.18 (tested with Hermit 24.15.0), POSIX process groups and

View File

@@ -7,6 +7,7 @@ import { claudeGuidance } from './claude.ts';
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 { Profiles, profile, profileRevision, type Profile } from './profiles.ts';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
@@ -26,7 +27,8 @@ 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
setup <host-directory> [identity-file] New host or existing local wizard
setup <host-directory> Offline host pairing/owner approval/import
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
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
@@ -54,6 +56,9 @@ async function main() {
showPresets();
} else if (command === 'setup') {
const dir = resolve(text(args[0]));
if (args.length === 1 && !existsSync(join(dir, 'setup.json'))) {
await enrollmentInput(dir); return;
}
if (existsSync(dir)) {
if (args.length !== 1) throw Error('Existing setup: supply host directory only; retained owner cannot be replaced');
await localSetup(dir); return;

View File

@@ -0,0 +1,39 @@
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { resolve } from 'node:path';
import { bootstrapHostIdentity, enrollHostIdentity, readHostIdentity } from './host-identity.ts';
import { hostPairing, pairingFingerprint, registerHost } from './host-registration.ts';
import { readPrivate, writePrivate } from './storage.ts';
import { readAgentSecret } from './key-input.ts';
import { text } from './protocol.ts';
/** Standalone offline enrollment UI. File exchange avoids circular relay bootstrap. */
export async function enrollmentInput(directory: string): Promise<void> {
const ui = createInterface({ input: stdin, output: stdout });
try {
console.log('Private infrastructure enrollment. No relay connection, agent authority, or runtime launch. Approval must run in your owner context, not on an untrusted host.');
const action = await ui.question('Enrollment [create / export / approve / import / cancel]: ');
if (action === 'cancel') return;
if (action === 'create' || action === 'export') {
const identity = action === 'create' ? bootstrapHostIdentity(directory, text(await ui.question('Host label: ')), text(await ui.question('Owner PUBLIC key (hex): ')), text(await ui.question('Relay URL: '))) : readHostIdentity(directory);
console.log(`Pairing fingerprint: ${pairingFingerprint(identity.pairing)}`);
const file = resolve(text(await ui.question('NEW private pairing file: ')));
writePrivate(file, identity.pairing, true);
console.log('Pairing exported. Transfer privately to your standalone owner context. Pending/no network. Export can be repeated if interrupted.');
} else if (action === 'approve') {
const request = hostPairing(readPrivate(resolve(text(await ui.question('Private pairing file: ')))));
console.log(`Host: ${request.host}\nLabel: ${request.label}\nOwner: ${request.owner}\nRelay: ${request.relay}\nFingerprint: ${pairingFingerprint(request)}`);
if (await ui.question('Compare full fingerprint with host. Register ONLY this infrastructure (not agents or relay delegation)? [yes/no]: ') !== 'yes') return;
const expires = Number(await ui.question('Registration expiration (Unix seconds): '));
if (!Number.isSafeInteger(expires) || expires <= Math.floor(Date.now() / 1000)) throw Error('Expiration must be in the future');
const file = resolve(text(await ui.question('NEW private approval file: ')));
ui.close();
const secret = await readAgentSecret('Owner');
writePrivate(file, registerHost(request, secret, expires), true);
console.log('Registration signed. Owner secret not persisted. Transfer approval privately back to host. Relay admission remains pending.');
} else if (action === 'import') {
enrollHostIdentity(directory, readPrivate(resolve(text(await ui.question('Private approval file: ')))));
console.log('Host registered. Pending/no network: narrow relay admission is not implemented. No agent authorized or started.');
} else throw Error('Unknown enrollment action');
} finally { ui.close(); }
}

View File

@@ -1,7 +1,10 @@
import { schnorr } from '@noble/curves/secp256k1';
import { digest, publicKey } from './protocol.ts';
/** NIP-OA tag as consumed by Buzz 051c3a2 nip_oa.rs. No owner secret is needed to verify/import. */
/** Historical source-contract fixture only: NIP-OA as consumed by Buzz 051c3a2.
* NOT infrastructure enrollment. AUTH ignores kind clauses and may establish an
* agent-owner relationship. Production host registration must never use this tag.
*/
export type HostAttestation = ['auth', string, string, string];
function clauses(conditions: string): string[] {
if (conditions.length > 1024) throw Error('Attestation conditions too long');

View File

@@ -1,45 +1,47 @@
import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
import { join } from 'node:path';
import { fields, newKey, object, publicKey, text } from './protocol.ts';
import { fields, newKey, object, publicKey } from './protocol.ts';
import { readPrivate, writePrivate } from './storage.ts';
import { verifyHostAttestation, type HostAttestation } from './host-attestation.ts';
import { hostPairing, verifyHostRegistration, type HostPairing, type HostRegistration } from './host-registration.ts';
/** Independent management identity. Agent slots and assignment authority are separate. */
export type HostIdentity = { version: 1; label: string; owner: string; secret: string; attestation: HostAttestation | null };
/** Infrastructure registration is separate from relay admission and agent placement. */
export type HostIdentity = { version: 2; 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');
mkdirSync(lock, { mode: 0o700 });
try { return operation(); } finally { rmdirSync(lock); }
}
/** Owner-public-only bootstrap. Pending until genuine owner attestation import; never migrates legacy keys. */
export function bootstrapHostIdentity(directory: string, label: string, owner: string): HostIdentity {
text(label);
if (!/^[0-9a-f]{64}$/.test(owner)) throw Error('Expected owner public key');
/** Offline bootstrap; neither requires admission nor copies the owner's secret. */
export function bootstrapHostIdentity(directory: string, label: string, owner: string, relay: string): HostIdentity {
return locked(directory, () => {
if (existsSync(join(directory, 'setup.json'))) throw Error('Legacy installation retained; use a clean directory');
const identity: HostIdentity = { version: 1, label, owner, secret: newKey(), attestation: null };
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);
return identity;
});
}
/** Read local identity without converting absent/expired enrollment into trust. */
/** Reject legacy broad OA records rather than silently migrating their authority. */
export function readHostIdentity(directory: string): HostIdentity {
const value = object(readPrivate(join(directory, 'host-identity.json')));
fields(value, ['version', 'label', 'owner', 'secret', 'attestation']);
if (value.version !== 1 || typeof value.owner !== 'string' || !/^[0-9a-f]{64}$/.test(value.owner) || typeof value.secret !== 'string' || !/^[0-9a-f]{64}$/.test(value.secret)) throw Error('Invalid host identity');
text(value.label); publicKey(value.secret);
return value as HostIdentity;
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');
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 };
}
/** Atomically import genuine authorization under the same installation lock as lifecycle setup. */
export function enrollHostIdentity(directory: string, attestation: unknown, now = Math.floor(Date.now() / 1000)): void {
/** 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 {
locked(directory, () => {
const identity = readHostIdentity(directory);
identity.attestation = verifyHostAttestation(attestation, publicKey(identity.secret), identity.owner, now);
identity.registration = verifyHostRegistration(registration, identity.pairing, now);
writePrivate(join(directory, 'host-identity.json'), identity);
});
}
/** Admission prerequisite, not a substitute for message/assignment authorization. */
export function requireHostEnrollment(identity: HostIdentity, now = Math.floor(Date.now() / 1000)): HostAttestation {
return verifyHostAttestation(identity.attestation, publicKey(identity.secret), identity.owner, now);
/** Valid registration is NOT relay admission. Remains no-network until a narrow contract exists. */
export function requireHostEnrollment(identity: HostIdentity, now = Math.floor(Date.now() / 1000)): never {
verifyHostRegistration(identity.registration, identity.pairing, now);
throw Error('Host registered locally; relay admission pending: broad NIP-OA owner delegation is prohibited');
}

View File

@@ -0,0 +1,43 @@
import { schnorr } from '@noble/curves/secp256k1';
import { digest, fields, object, publicKey, text } from './protocol.ts';
/** Offline pairing request. This is private file-exchange material, not a relay event. */
export type HostPairing = { version: 1; purpose: 'beehive-host-registration'; host: string; owner: string; label: string; relay: string; nonce: string };
/** Owner endorsement of infrastructure only; never a NIP-OA tag or relay admission. */
export type HostRegistration = { request: HostPairing; expires: number; signature: string };
/** Validate untrusted pairing files before displaying or signing them. */
export function hostPairing(value: unknown): HostPairing {
const p = object(value);
fields(p, ['version', 'purpose', 'host', 'owner', 'label', 'relay', 'nonce']);
if (p.version !== 1 || p.purpose !== 'beehive-host-registration') throw Error('Invalid registration purpose');
for (const key of ['host', 'owner', 'nonce']) if (typeof p[key] !== 'string' || !/^[0-9a-f]{64}$/.test(p[key] as string)) throw Error('Invalid pairing identity/nonce');
if (p.host === p.owner) throw Error('Host must have independent identity');
const label = text(p.label);
if (label.length > 128 || /[\x00-\x1f\x7f]/.test(label)) throw Error('Invalid host label');
const relay = text(p.relay); const url = new URL(relay);
if (relay.length > 2048 || url.username || url.password || url.hash || url.search || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && url.hostname === '127.0.0.1'))) throw Error('Invalid pairing relay');
return { version: 1, purpose: 'beehive-host-registration', host: p.host as string, owner: p.owner as string, label, relay, nonce: p.nonce as string };
}
/** Full fingerprint binds host, owner, label, relay and this installation request. */
export function pairingFingerprint(value: unknown): string {
return digest(JSON.stringify(hostPairing(value))).toString('hex');
}
function preimage(request: HostPairing, expires: number): Buffer {
if (!Number.isSafeInteger(expires) || expires <= 0) throw Error('Invalid registration expiration');
return digest(`beehive:host-registration:v1:${pairingFingerprint(request)}:${expires}`);
}
/** Explicit existing-owner signing; the caller must not persist the owner secret. */
export function registerHost(request: unknown, ownerSecret: string, expires: number): HostRegistration {
const pairing = hostPairing(request);
if (pairing.owner !== publicKey(ownerSecret)) throw Error('Wrong owner signer');
return { request: pairing, expires, signature: Buffer.from(schnorr.sign(preimage(pairing, expires), ownerSecret)).toString('hex') };
}
/** Verifies only registration and locally observed expiration, not remote revocation or admission. */
export function verifyHostRegistration(value: unknown, expected: unknown, now = Math.floor(Date.now() / 1000)): HostRegistration {
const r = object(value); fields(r, ['request', 'expires', 'signature']);
const request = hostPairing(r.request);
if (pairingFingerprint(request) !== pairingFingerprint(expected)) throw Error('Registration does not match pairing request');
if (typeof r.expires !== 'number' || !Number.isSafeInteger(now) || now >= r.expires || typeof r.signature !== 'string' || !/^[0-9a-f]{128}$/.test(r.signature)) throw Error('Invalid/expired host registration');
if (!schnorr.verify(r.signature, preimage(request, r.expires), request.owner)) throw Error('Invalid owner registration signature');
return { request, expires: r.expires, signature: r.signature };
}

View File

@@ -5,11 +5,11 @@ import { publicKey } from './protocol.ts';
/** Local secret entry only: disable terminal echo and never route key bytes to output.
* Piped input is supported for protected automation; no secret argv or environment. */
export async function readAgentSecret(): Promise<string> {
export async function readAgentSecret(role: 'Agent' | 'Owner' = 'Agent'): Promise<string> {
const muted = new Writable({ write(_chunk, _encoding, done) { done(); } });
const ui = createInterface({ input: stdin, output: muted, terminal: Boolean(stdin.isTTY) });
try {
stdout.write('Agent private key (64 hex; hidden, never passed as an argument): ');
stdout.write(`${role} private key (64 hex; hidden, never passed as an argument): `);
const value = await ui.question('');
if (!/^[0-9a-fA-F]{64}$/.test(value)) throw Error('Invalid private key; expected 64 hex characters');
const secret = value.toLowerCase();

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
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 };
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'] });
let output = '', cursor = 0, next = 0;
const timer = setTimeout(() => child.kill(), 10000);
child.stdout.on('data', data => {
output += data.toString();
if (next < steps.length) {
const [prompt, answer] = steps[next]!;
const index = output.indexOf(prompt, cursor);
if (index !== -1) { cursor = index + prompt.length; next++; child.stdin.write(`${answer}\n`); }
}
});
child.stderr.on('data', data => { output += data.toString(); });
try {
const code = await new Promise<number | null>((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); });
assert.equal(code, 0, output); assert.equal(next, steps.length, output);
return output;
} finally { clearTimeout(timer); child.stdin.end(); }
}
test('actual standalone CLI private pairing, existing-owner approval and bootstrap return stay offline', async () => {
const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-'));
const host = join(root, 'host'), ownerContext = join(root, 'owner-context');
const request = join(root, 'request.json'), approval = join(root, 'approval.json');
const ownerSecret = newKey();
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);
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],
]);
assert.ok(signed.includes(pairingFingerprint(original.pairing)));
assert.ok(!signed.includes(ownerSecret));
const imported = await wizard(host, [['Enrollment [', 'import'], ['Private approval file: ', approval]]);
assert.ok(imported.includes('Pending/no network'));
const enrolled = readHostIdentity(host);
assert.equal(enrolled.secret, original.secret);
assert.deepEqual(enrolled.pairing, original.pairing);
assert.throws(() => requireHostEnrollment(enrolled), /relay admission pending/);
assert.deepEqual(readdirSync(host), ['host-identity.json']);
for (const path of [request, approval, join(host, 'host-identity.json')]) assert.ok(!readFileSync(path, 'utf8').includes(ownerSecret));
} finally { rmSync(root, { recursive: true, force: true }); }
});

View File

@@ -1,3 +1,4 @@
import { registerHost, verifyHostRegistration } from '../src/host-registration.ts';
import test from 'node:test';
import { WebSocket } from 'ws';
import assert from 'node:assert/strict';
@@ -68,20 +69,29 @@ 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));
assert.equal(identity.attestation, null);
const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner), 'wss://example.invalid');
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.throws(() => bootstrapHostIdentity(directory, 'other', identity.owner));
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));
rmdirSync(join(directory, 'host.lock'));
assert.throws(() => enrollHostIdentity(directory, attestHost(hex(generateSecretKey()), getPublicKey(Buffer.from(identity.secret, 'hex')), '')));
enrollHostIdentity(directory, tag);
assert.deepEqual(requireHostEnrollment(readHostIdentity(directory)), tag);
assert.throws(() => enrollHostIdentity(directory, tag), '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()) };
assert.throws(() => verifyHostRegistration(registration, changed));
}
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/);
} finally { rmSync(directory, { recursive: true, force: true }); }
});