From 9aef40a3f626200ef1a702c64e315cad5339f2d3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 16 Sep 2026 13:22:09 -0400 Subject: [PATCH] fix(beehive): derive first-use owner directly from Buzz key Bypass manual routing for Buzz sign-in, derive and validate identity in the controller, and initialize only public owner routing using the current Host relay after the stale/cancel fence. Retained owners still must match; custody remains session-only. Regression drives the real Node/Bun UI once with no controller owner configured and an explicit synthetic credential fixture. Focused 12-test checks, typecheck, and first-use PTY walkthrough pass. Earlier broad-suite 400-second failure remains unresolved; no production Keychain access or native rebuild. Signed-off-by: Logan Johnson --- beehive/BUZZ_OWNER_SIGNIN.md | 50 ++++++++-- beehive/src/buzz-owner-key.ts | 7 +- beehive/src/manager-controller.ts | 12 ++- beehive/src/manager-view.ts | 12 +-- beehive/test/buzz-first-use-smoke.py | 112 ++++++++++++++++++++++ beehive/test/buzz-owner-key.test.ts | 18 ++-- beehive/test/manager-installed-fixture.ts | 2 +- 7 files changed, 185 insertions(+), 28 deletions(-) create mode 100644 beehive/test/buzz-first-use-smoke.py diff --git a/beehive/BUZZ_OWNER_SIGNIN.md b/beehive/BUZZ_OWNER_SIGNIN.md index 37488f3132..912e401224 100644 --- a/beehive/BUZZ_OWNER_SIGNIN.md +++ b/beehive/BUZZ_OWNER_SIGNIN.md @@ -36,16 +36,20 @@ not a claim that the OS supports reading one JSON member independently. ## Secret and authority flow 1. Renderer sends the explicit `signin-buzz` action with public routing only. -2. Controller retains existing owner/relay when configured; otherwise the human - supplies them. Nothing is inferred from the discovered key. +2. The Buzz action bypasses the manual routing wizard entirely. Controller retains + an existing owner/relay; on first use it takes the current Host relay, reads and + validates Buzz’s key with an explicit null expected-owner mode, then derives + the public owner from that key. Renderer supplies neither identity nor secret. + Only the public owner/relay binding is initialized via createControllerConfig, + after the cancellation/generation fence; no host identity is changed. 3. Existing bounded Node credential child calls pinned `@napi-rs/keyring` via `loadNativeEntry()`, constructs the exact item, and calls **getPassword only**. Parse is bounded to a 1 MiB blob. Missing item/member, native access failure, malformed JSON/nsec/invalid scalar, and owner mismatch are distinct fixed outcomes; native denial/cancellation cannot reliably be distinguished and are reported together, not described as successful or as missing. -4. Only validated matching hex secret returns over private IPC. The controller - checks generation/cancellation and independently validates the owner match +4. Only validated hex secret returns over private IPC. The controller + checks generation/cancellation and independently derives the public identity and validates any retained owner match before routing persistence/transport creation. Error text never includes native/parser exceptions or key material. 5. Existing management-client and authenticated directory code consume the @@ -107,5 +111,39 @@ state, not an off-screen Sign out label. No native source changed or native package was rebuilt/tested. No production Keychain access, provider/relay calls, install, push, or PR. OS prompt interaction, actual production-item compatibility, installed feature delivery, and independent -security review remain unvalidated. Existing guarded publication/export route -must wait for that review. +security review remain unvalidated. These historical limitations are not a gate on the explicitly requested prototype publication. + + +## First-use regression and laptop smoke (current fix) + +The prior one-gesture fixture had controller routing preconfigured and therefore +missed the actual first-use wizard. `test/buzz-first-use-smoke.py` now drives the +real Node controller + Bun renderer through Agents → Actions → Buzz key → Enter +once. Preconditions are explicit: a fresh temporary HOME, **no controller.json or +configured controller owner**, a normal synthetic Host with `wss://fixture.invalid`, +and an explicit synthetic credential file. The fixture selects the synthetic Buzz +identity independently of the requested owner (`BEEHIVE_TEST_BUZZ_OWNER`), matching +the canonical reader's first-use contract. There are no production Keychain reads. +Directory, services, native OAuth, providers and transport remain fenced by the +existing loader; signed-in UI is verified, not real relay connectivity. + +Run from `beehive/` with real runtime binaries (not Hermit shim symlinks): + +```sh +BEEHIVE_SMOKE_NODE=/absolute/path/to/node-24.15.0/bin/node \ +BEEHIVE_BUN=/absolute/path/to/packaged/bun \ +python3 -u test/buzz-first-use-smoke.py +``` + +The test checks the derived public config, signed-in rendered state, absence of +public-key/Step 1/nsec/Type yes prompts throughout terminal replay, unchanged +credential file, and no private-key bytes in Beehive storage or terminal output. +It prints the surviving temporary HOME with `ui.ansi` and `ui-frames.txt`. +Actual walkthrough passed with Node 24.15.0 and packaged Bun 1.4.2. Focused backend +checks remain 12/12 and TypeScript passes; cancellation/close now also exercise +unconfigured first use, and the real credential-child check covers null owner. +The earlier broad-suite 400-second failure above remains unresolved; no full-suite +retry, native rebuild, production Keychain access, or installation was performed. +For laptop acceptance: retain the normal Host, ensure no controller owner has yet +been configured, then select Buzz-key sign-in once. Existing configured owners +still must match. Do not delete or retarget a real retained owner just to smoke. diff --git a/beehive/src/buzz-owner-key.ts b/beehive/src/buzz-owner-key.ts index 02e2639776..eb4f6a0506 100644 --- a/beehive/src/buzz-owner-key.ts +++ b/beehive/src/buzz-owner-key.ts @@ -5,8 +5,9 @@ import { publicKey } from './protocol.ts'; /** Release Desktop source contract: secret_store.rs BLOB_KEY; app_state.rs * IDENTITY_KEY_NAME; app_state_keyring.rs keyring_service (6d1f488d). * The containing item also holds agent keys. Never return it, enumerate its - * members, migrate legacy entries, or call a write API. Explicit sign-in only. */ -export function readBuzzOwnerKey(owner: string, read: () => string | null = () => new (loadNativeEntry())('buzz-desktop', 'secrets').getPassword()): { ok: true; secret: string } | { ok: false; reason: 'missing' | 'access' | 'malformed' | 'mismatch' } { + * members, migrate legacy entries, or call a write API. Explicit sign-in only. + * null explicitly permits first-use identity derivation; undefined is not first use. */ +export function readBuzzOwnerKey(owner: string | null, read: () => string | null = () => new (loadNativeEntry())('buzz-desktop', 'secrets').getPassword()): { ok: true; secret: string } | { ok: false; reason: 'missing' | 'access' | 'malformed' | 'mismatch' } { let raw: string | null; try { raw = read(); } catch { return { ok: false, reason: 'access' }; } if (raw === null) return { ok: false, reason: 'missing' }; @@ -19,7 +20,7 @@ export function readBuzzOwnerKey(owner: string, read: () => string | null = () = if (typeof blob.identity !== 'string') throw Error(); secret = agentNsec(blob.identity); } catch { return { ok: false, reason: 'malformed' }; } - if (publicKey(secret) !== owner) return { ok: false, reason: 'mismatch' }; + if (owner !== null && publicKey(secret) !== owner) return { ok: false, reason: 'mismatch' }; return { ok: true, secret }; } diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 60a681877d..7d2eaf8979 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -439,16 +439,22 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } check(); this.status = 'Local agent added and stopped. Nothing was started or published to the relay.'; } else if (request.action === 'signin' || request.action === 'signin-buzz') { const retained = readControllerConfig(this.ownerDirectory); - const owner = retained?.owner ?? ownerPublicInput(v.owner ?? ''); - const relay = retained?.relay ?? v.relay ?? ''; + let owner = retained?.owner ?? (request.action === 'signin-buzz' ? null : ownerPublicInput(v.owner ?? '')); + const relay = retained?.relay ?? (request.action === 'signin-buzz' + ? readHostIdentityPublic(this.hostDirectory).pairing.relay : v.relay ?? ''); if (!/^(wss|ws):\/\//.test(relay)) throw plain('Enter a relay URL that starts with ws:// or wss://.'); const result = await this.credential({ action: request.action, owner, ...(request.action === 'signin' && v.secret ? { secret: v.secret.toLowerCase() } : {}) }, abort.signal); delete v.secret; check(); if (request.action === 'signin-buzz') { let matches = false; - try { matches = typeof result.secret === 'string' && publicKey(result.secret) === owner; } catch { /* Untrusted helper result: never expose its bytes. */ } + try { + const derived = typeof result.secret === 'string' ? publicKey(result.secret) : undefined; + matches = derived !== undefined && (owner === null || derived === owner); + if (matches && derived !== undefined) owner = derived; + } catch { /* Untrusted helper result: never expose its bytes. */ } if (!matches) throw plain('Buzz key is unavailable, malformed, or does not match the configured Beehive owner. No owner or relay was changed.'); } + if (owner === null) throw plain('Buzz Keychain owner key is malformed. No key or configured owner was changed.'); if (result.secret === null) throw plain('No saved owner key. Choose Import matching owner key and sign in. Enter the matching private key with 64 hex characters.'); if (!retained) createControllerConfig(this.ownerDirectory, owner, relay); this.client?.close(); this.inventory.clear(); this.offers.clear(); this.directory = []; diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 7b053d4e60..84ee473a83 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -46,14 +46,8 @@ async function routing(action: string, secret = false) { if (key === undefined) return; values.secret = key; } - // Buzz-key sign-in carries no extra Beehive prompt: selecting the action is - // itself the explicit gesture (an OS keychain permission dialog is separate - // and still applies), so the request dispatches immediately. The saved-key - // and import paths keep their confirmation. - if (action !== 'signin-buzz') { - const confirmation = `${action === 'configure' ? 'Configure this computer' : 'Sign in'}\nOwner: ${shortIdentity(owner)}\nRelay: ${relay}\n${action === 'configure' ? 'Create host identity in the OS credential store?' : 'Access owner key in the OS credential store?'}`; - if (!await screen.confirm(confirmation)) { delete values.secret; return; } - } + const confirmation = `${action === 'configure' ? 'Configure this computer' : 'Sign in'}\nOwner: ${shortIdentity(owner)}\nRelay: ${relay}\n${action === 'configure' ? 'Create host identity in the OS credential store?' : 'Access owner key in the OS credential store?'}`; + if (!await screen.confirm(confirmation)) { delete values.secret; return; } await request(action, values); delete values.secret; } async function registerForm(continuation?: string, agent?: string) { @@ -157,7 +151,7 @@ The source stops before destination launch. Workspace, session and credentials s { label: 'Sign out', run: () => request('signout') }, { label: 'Publish profile or instructions', run: () => screen.notice('Publication forms are unavailable. Use the CLI:\nbeehive drafts ~/.beehive/owner\nbeehive tui discover ~/.beehive/owner\nThis build cannot generate owner keys.') }, ] : [ - { label: 'Sign in with Buzz key', run: () => routing('signin-buzz') }, + { label: 'Sign in with Buzz key', run: () => request('signin-buzz') }, { label: 'Sign in with saved owner key', run: () => routing('signin') }, { label: 'Import matching owner key and sign in', run: () => routing('signin', true) }, ]; diff --git a/beehive/test/buzz-first-use-smoke.py b/beehive/test/buzz-first-use-smoke.py new file mode 100644 index 0000000000..3b43e20f86 --- /dev/null +++ b/beehive/test/buzz-first-use-smoke.py @@ -0,0 +1,112 @@ +"""Real Node controller + Bun renderer, synthetic credentials only; run from beehive. +BEEHIVE_SMOKE_NODE and BEEHIVE_BUN must point to Node 24.15.0 and packaged Bun. +No configured controller owner: only a normal Host and a synthetic Buzz key exist. +""" +import os, pty, select, time, struct, fcntl, termios, json, tempfile, subprocess +from pathlib import Path +root = os.getcwd() +home = tempfile.mkdtemp(prefix='beehive-pairing-cli-buzz-first-use-') +node = os.environ['BEEHIVE_SMOKE_NODE'] +env = {'PATH': '/usr/bin:/bin', 'HOME': home, 'TERM': 'xterm-256color', + 'BEEHIVE_TEST_CREDENTIAL_FILE': home+'/credentials.json', + 'BEEHIVE_BUN': os.environ['BEEHIVE_BUN'], + 'NODE_OPTIONS': '--import='+root+'/test/manager-installed-loader.ts'} +seed = """import {bootstrapHostIdentity} from './src/host-identity.ts'; +import {createCredential} from './src/credential-store.ts'; +import {publicKey} from './src/protocol.ts'; +import {isolatedFileCredentials} from './test/isolated-file-credentials.ts'; +const key='1'.repeat(64), backend=isolatedFileCredentials(process.env.BEEHIVE_TEST_CREDENTIAL_FILE); +createCredential('owner',key,backend); +bootstrapHostIdentity(process.env.HOME+'/.beehive/host','Fixture Host',publicKey(key),'wss://fixture.invalid',backend); +console.log(publicKey(key));""" +owner = subprocess.check_output([node, '--input-type=module', '-e', seed], env=env, text=True).strip() +env['BEEHIVE_TEST_BUZZ_OWNER'] = owner +config = Path(home+'/.beehive/owner/controller.json') +assert not config.exists(), 'Regression must start with NO configured owner' +before = Path(env['BEEHIVE_TEST_CREDENTIAL_FILE']).read_bytes() +pid, fd = pty.fork() +if pid == 0: + os.execve(node, [node, 'src/manager-entry.ts'], env) +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 36, 140, 0, 0)) +transcript = b'' +frames = [] +import re +ROWS, COLS = 36, 140 +def screen(buf): + cells={}; row=col=1; i=0; n=len(buf) + while i=n: break + params=buf[i+2:j].decode('latin1'); final=buf[j:j+1].decode('latin1') + if final=='H': + p=params.split(';') + try: row=int(p[0]) if p[0] else 1; col=int(p[1]) if len(p)>1 and p[1] else 1 + except ValueError: pass + elif final=='J' and params in ('','0','2'): cells={} + elif final=='K': + for c in range(col,COLS+1): cells.pop((row,c),None) + i=j+1; continue + if b==27 and i+1 Buzz key once -> derived owner signed in; no prompts or private copy. HOME='+home) +finally: + Path(home+'/ui.ansi').write_bytes(transcript) + Path(home+'/ui-frames.txt').write_text('\n--- FRAME ---\n'.join(frames)) + os.killpg(pid, 9) + os.waitpid(pid, 0) diff --git a/beehive/test/buzz-owner-key.test.ts b/beehive/test/buzz-owner-key.test.ts index 28a1006dd3..5736a1701a 100644 --- a/beehive/test/buzz-owner-key.test.ts +++ b/beehive/test/buzz-owner-key.test.ts @@ -8,6 +8,8 @@ import { readBuzzOwnerKey, buzzOwnerKeyErrors } from '../src/buzz-owner-key.ts'; import { createControllerConfig } from '../src/controller-config.ts'; import { managerCredential } from '../src/manager-credential.ts'; import { ManagerController } from '../src/manager-controller.ts'; +import { bootstrapHostIdentity } from '../src/host-identity.ts'; +import { isolatedFileCredentials } from './isolated-file-credentials.ts'; import { publicKey } from '../src/protocol.ts'; const secret = '1'.repeat(64), owner = publicKey(secret); @@ -27,6 +29,7 @@ test('explicit Buzz signin validates actual blob identity and excludes all other ['wrong owner', () => JSON.stringify({identity:nip19.nsecEncode(Buffer.from('2'.repeat(64),'hex'))}), 'mismatch'], ] as const) { const home = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-buzz-owner-')); + createControllerConfig(join(home,'.beehive','owner'),owner,'wss://fixture.invalid'); let reads = 0, connections = 0, directoryReads = 0; const outputs: unknown[] = []; const c = new ManagerController(home, s => outputs.push(s), async (input) => { @@ -46,7 +49,7 @@ test('explicit Buzz signin validates actual blob identity and excludes all other assert.equal(result.state, reason ? 'failed' : 'completed',label); assert.equal(connections,reason ? 0 : 1,label); const config = join(home,'.beehive','owner','controller.json'); - if (reason) { assert.equal(c.snapshot().owner,undefined); assert.equal(existsSync(config),false); } + if (reason) { assert.equal(c.snapshot().owner,undefined); assert.deepEqual(JSON.parse(readFileSync(config,'utf8')), {version:1,owner,relay:'wss://fixture.invalid'}); } else { assert.equal(c.snapshot().owner,owner); assert.deepEqual(JSON.parse(readFileSync(config,'utf8')), {version:1,owner,relay:'wss://fixture.invalid'}); @@ -62,18 +65,20 @@ test('explicit Buzz signin validates actual blob identity and excludes all other }); test('Buzz late completion and untrusted helper mismatch cannot configure or connect', async () => { - for (const mode of ['cancel','close','mismatch','malformed'] as const) { + for (const mode of ['cancel','close','mismatch','malformed','first-use-cancel','first-use-close','first-use-malformed'] as const) { const home = mkdtempSync(join(tmpdir(),'beehive-pairing-cli-buzz-stale-')); + bootstrapHostIdentity(join(home,'.beehive','host'),'Fixture',owner,'wss://fixture.invalid',isolatedFileCredentials(join(home,'credentials.json'))); + if (!mode.startsWith('first-use')) createControllerConfig(join(home,'.beehive','owner'),owner,'wss://fixture.invalid'); let finish!: (value: unknown) => void; const c = new ManagerController(home,()=>{},()=>new Promise(resolve=>{finish=resolve;}), (()=>{throw Error('must never connect');}) as any, async()=>undefined); try { const pending = c.request({id:1,action:'signin-buzz',values:{owner,relay:'wss://fixture.invalid'}}); - if (mode === 'cancel') c.cancel(); - if (mode === 'close') c.close(); - finish({ok:true,secret:mode === 'mismatch' ? '2'.repeat(64) : mode === 'malformed' ? nsec : secret}); + if (mode.endsWith('cancel')) c.cancel(); + if (mode.endsWith('close')) c.close(); + finish({ok:true,secret:mode === 'mismatch' ? '2'.repeat(64) : mode.endsWith('malformed') ? nsec : secret}); assert.notEqual((await pending).state,'completed'); assert.equal(c.snapshot().owner,undefined); - assert.equal(existsSync(join(home,'.beehive','owner','controller.json')),false); + assert.equal(existsSync(join(home,'.beehive','owner','controller.json')),!mode.startsWith('first-use')); assert.ok(!JSON.stringify(c.snapshot()).includes(nsec)); } finally { c.close(); rmSync(home,{recursive:true,force:true}); } } @@ -82,6 +87,7 @@ test('Buzz late completion and untrusted helper mismatch cannot configure or con test('actual signin child reads only the canonical item; safe missing/access errors and no Beehive copy', async () => { const helper = new URL('./buzz-owner-child-fixture.ts', import.meta.url); + assert.deepEqual(await managerCredential({action:'signin-buzz',owner:null,fixture:{blob}},new AbortController().signal,helper),{ok:true,secret}); assert.deepEqual(await managerCredential({action:'signin-buzz',owner,fixture:{blob}},new AbortController().signal,helper),{ok:true,secret}); for (const [fixture, pattern] of [[{blob:null},/No Buzz owner key/],[{denied:nsec},/locked, denied, cancelled/],[{blob:'invalid'},/malformed/],[{blob},/does not match/]] as const) { await assert.rejects(managerCredential({action:'signin-buzz',owner:pattern.source.includes('does not match') ? publicKey('2'.repeat(64)) : owner,fixture},new AbortController().signal,helper), error => { diff --git a/beehive/test/manager-installed-fixture.ts b/beehive/test/manager-installed-fixture.ts index 7ad78e740a..864d5ab813 100644 --- a/beehive/test/manager-installed-fixture.ts +++ b/beehive/test/manager-installed-fixture.ts @@ -25,7 +25,7 @@ export async function fixtureCredential(input: any, signal: AbortSignal) { // Esc cancellation deterministically; inert unless the driver sets it. const paced = Number(process.env.BEEHIVE_TEST_SIGNIN_DELAY_MS ?? '0'); if (paced > 0) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, paced); signal.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); }); - const key = backend.read(credentialReference('owner', input.owner)); + const key = backend.read(credentialReference('owner', process.env.BEEHIVE_TEST_BUZZ_OWNER ?? input.owner)); const result = readBuzzOwnerKey(input.owner, () => key === null ? null : JSON.stringify({identity:nip19.nsecEncode(Buffer.from(key,'hex'))})); if (!result.ok) throw Error(buzzOwnerKeyErrors[result.reason]); return result;