From a8e2424ee9aec9fb6ceaeb3488c1ce0ea495405f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 12:26:10 -0400 Subject: [PATCH] feat(beehive): checkpoint independent-identity Nostr management tracer Signed-off-by: Logan Johnson --- beehive/REAL_HOST_TRANSPORT_473233B8.md | 141 +++++++++++++++++++++ beehive/package.json | 2 +- beehive/pnpm-lock.yaml | 70 +++++++++++ beehive/src/host-attestation.ts | 34 +++++ beehive/src/host-identity.ts | 45 +++++++ beehive/src/nostr-client.ts | 84 +++++++++++++ beehive/src/nostr-codec.ts | 49 ++++++++ beehive/test/nostr-fixture.ts | 58 +++++++++ beehive/test/nostr-transport.test.ts | 158 ++++++++++++++++++++++++ 9 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 beehive/REAL_HOST_TRANSPORT_473233B8.md create mode 100644 beehive/src/host-attestation.ts create mode 100644 beehive/src/host-identity.ts create mode 100644 beehive/src/nostr-client.ts create mode 100644 beehive/src/nostr-codec.ts create mode 100644 beehive/test/nostr-fixture.ts create mode 100644 beehive/test/nostr-transport.test.ts diff --git a/beehive/REAL_HOST_TRANSPORT_473233B8.md b/beehive/REAL_HOST_TRANSPORT_473233B8.md new file mode 100644 index 0000000000..f6ae61e0ab --- /dev/null +++ b/beehive/REAL_HOST_TRANSPORT_473233B8.md @@ -0,0 +1,141 @@ +# Real host transport — partial tracer checkpoint (NOT deployable) + +Base: `419370d38c34f7866e1cbf59b88d9db2bc3756bc`. Isolated branch +`beehive/demo-transport-473233b8`; no moving-branch mesh code included. + +## Implemented + +- `src/nostr-codec.ts`: pinned `nostr-tools@2.23.1` NIP-44 v2 pairwise encryption, + signed kind13 seal and fresh ephemeral kind1059 gift wrap. Inner kind14 rumor + carries JSON Message and a private management subject discriminator; no new + relay kind allocation. Verifies outer and seal signatures, rumor hash, inner + sender binding, recipient, domain and existing Message parser. Retains stable + Message operation ID across fresh wraps. NIP59 library interoperability tested. +- `src/nostr-client.ts`: stable signer NIP42 AUTH, optional OA tag, auth OK before + REQ, full-history/live subscription before ready (EOSE), EVENT/OK publication, + 64 pending ACK cap, payload/backpressure/startup/ACK bounds, challenge cap, + subscription CLOSED/socket errors reject pending unknown results. Single + generation; callers reopen explicitly. No automatic effect retries. +- `src/host-attestation.ts`: public verification and explicit owner-side signing + primitive matching the upstream OA preimage and canonical conditions. Includes + published upstream SDK signature vector. No credential harvesting or key daemon. +- `src/host-identity.ts`: owner-public-only durable independent host key, pending + by default, genuine OA import; private atomic writes and the existing host.lock + discipline. Refuses legacy setup.json and never migrates shared owner secrets. +- `authorizeManagement`: narrow tracer authority policy; owner commands must prove + owner and target label; inventory/receipts must prove exact supplied host key. + ALL host-to-host Move messages are denied pending real catalog/assignment audit. + The supplied catalog is a caller trust input, NOT yet an owner-attested record. + +No existing lifecycle/conversation code or normal CLI behavior was changed. These +are implemented seams awaiting integration, NOT a repaired user-facing demo. + +## Source proof (not deployed policy) + +Pinned upstream: `block/buzz@051c3a270be9c73da9ab06700bcab7d5552fceaa`. + +- `crates/buzz-relay/src/handlers/ingest.rs:2203–2280`: WebSocket-only gift wraps; + event signature validation; +/-900s timestamp; 256KB content; **explicit** + gift-wrap exception to author/authenticated-connection identity equality; + required scope comes from authenticated context (`MessagesWrite`, lines 471–472). +- `handlers/auth.rs:218–267`, `api/mod.rs:191–210`: NIP-OA checked on stable + identity's signed AUTH for ViaOwner membership. The ephemeral wrapper does not + need its own OA; its signature proves only ciphertext integrity, NOT authority. +- `buzz-sdk/src/nip_oa.rs:289–320`: AUTH verifies signature and temporal clauses, + deliberately ignoring `kind=`. Therefore `kind=1059` OA must NOT be described + as a management-command or ephemeral-wrapper authorization restriction. +- `buzz-core/src/kind.rs` P_GATED_KINDS and `handlers/req.rs` p-gated filter policy: + recipient-scoped giftwrap reads. Fixture enforces exact self #p filter. +- `desktop/src-tauri/src/commands/agents.rs:433–450`: owner signing is coupled to + managed-agent creation; no verified arbitrary-host-public-key minting UI/CLI + entrypoint was found. Do not create an agent draft to masquerade as a host. + +Crypto correction to the gap memo: NIP44 v2 uses ChaCha20 + HMAC-SHA256 with HKDF, +not ChaCha20-Poly1305. Production code imports the tested library, not handrolled +primitives. NIP59 convenience unwrap is insufficient as an authentication API +(it decrypts without the explicit seal/rumor verification implemented here). + +Timestamp decision: current timestamps instead of NIP59 recommended random +backdating meet the pinned relay admission window. This is a documented privacy +tradeoff, not transparent conformance to its backdating recommendation. Operation +identity is Message.id, not any outer/seal/rumor event ID. + +## Actual validation + +Node24.15.0: `/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node`. +Standalone pnpm11.4.0 `dist/pnpm.mjs`, approved mirror, frozen lock install, +no workspace bootstrap or borrowed dependencies. Direct package-local tsc. + +- Strict TypeScript: PASS. +- `node --test test/nostr-transport.test.ts`: **6/6 PASS**. +- Tests exercise real WS frames against a narrow executable admission model in + `test/nostr-fixture.ts`: owner + two independent host identities, genuine + fixture OA, AUTH, p-gating, inventory, valid owner Start message, host receipt, + connection reopen/history, fresh wrap same inner operation ID. Negative tests: + unsigned AUTH, no enrollment, timestamp outside admission, unsupported 30179, + altered signature/ciphertext, wrong recipient/rumor author, cross-host/owner + spoofing, denied Move grants, durable pending/import/lock behavior. +- Published NIP-OA SDK vector passes. NIP44/NIP59 interoperability with library + tested; official NIP44 vector corpus is NOT independently run in this checkpoint. +- This fixture models the cited policy subset, not the actual Rust relay/server + stack, token scopes, DB materialization, query pagination or deployed settings. +- Rewrap dedup test establishes equal inner IDs only; it deliberately does NOT + claim durable lifecycle effect dedup. No Start/Move execution over new transport. +- ONE full default-concurrent installed-enabled suite attempted with inherited + BUZZ_PRIVATE_KEY/BUZZ_AUTH_TAG/BUZZ_RELAY_URL removed and pinned Node PATH. + **NOT PASS**: log has 79 passing test records, one failure named + `same-batch Stop cancellation keeps authority, ordering, duplicate and revision fences`, + and no final summary before the tool's 120s bound. No rerun, serial mask, + timeout inflation, force-exit or latency classification. Failure details were + not printed before cutoff. Preserve this unresolved result. + +Installed binary SHA256 (separate from candidate TS source): + +- buzz-acp: `10612d0025d1420bdd9e9afcc2e7129377da2414e75049a8b640e81d857441ea` +- buzz: `147cc2ccf276ddedc5b84d13f5399a95282066303c9dca566bb2af5a37b856c5` + +Both from `/Applications/Buzz.app/Contents/MacOS/`; invoked only by fresh existing +fixture journeys. No owner profiles, live login/refresh/inference or live probes. + +## Self-review and required continuation + +**Security review gate remains CLOSED. Not a release candidate.** + +1. Integrate at connect/intents/host boundaries, not a parallel agent runtime. + Existing journals currently store legacy encrypted envelopes; replace durable + transport persistence with stable signed message identity plus fresh wrapping + while preserving exact fingerprint/CAS/cancellation/replay receipt semantics. +2. Owner-attested public host catalog and cryptographic host binding must replace + caller-supplied label mapping before trust. Move source/target authority needs + exact target/agent/fingerprint/retained-assignment verification; source-consumed + authority/outbox survives restart and target assigns before launch validation. + Do not relax current grant denial until this is implemented and tested. +3. Integrate bounded reconnect and policy-close recovery with existing durable + unknown-vs-accepted UI; current single-generation seam only exposes explicit + reopen. Full history queries may require production pagination reconciliation. +4. Normal CLI/local wizard must call owner-public-only bootstrap and genuine + attestation import. Current normal setup still uses legacy development transport; + do not deploy this branch or run normal setup for the requested real-host demo. + No desktop/laptop product setup commands are supported yet. +5. Provide explicit owner-side interactive signer entrypoint or use a verified + existing owner signer API. Current attestHost is only a library primitive. + Do not distribute/store owner secrets on hosts, copy Larry's auth tag, create + controller authority, or use fabricated live attestations. +6. Extend production-shaped tests through actual Start/reply/Move/same-agent reply + with installed Buzz + TS provider fixture. Exercise persistent lifecycle dedup, + cancellation, backpressure/closed/rechallenge/reconnect and catalog spoofing. + Fix/classify full-suite result without rerun-to-green; no readiness claim. +7. Independent security review at immutable pin BEFORE any install. Only after + review and legitimate owner authorization: enroll actual host keys and verify + deployed ViaOwner/MessagesWrite policy. Source proof is not live policy proof. + +Next executable developer action (not operator deployment): + +``` +cd /Users/loganj/.buzz/REPOS/beehive-demo-transport-473233b8/beehive +/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node --test test/nostr-transport.test.ts +``` + +Then implement the retained-message/host-key catalog integration above on this +single WIP branch. No mesh, existing native client edits, release migration, +repository migration, PR or deployment is authorized by this checkpoint. diff --git a/beehive/package.json b/beehive/package.json index 981237e826..a45f36fe23 100644 --- a/beehive/package.json +++ b/beehive/package.json @@ -1 +1 @@ -{"name":"@buzz/beehive","version":"0.0.1","private":true,"type":"module","engines":{"node":">=22.18"},"scripts":{"check":"tsc --noEmit","test":"node --test test/*.test.ts","start":"node src/cli.ts"},"dependencies":{"@noble/curves":"1.2.0","ws":"8.21.0"},"devDependencies":{"@types/node":"22.18.0","@types/ws":"8.18.1","typescript":"5.9.3"}} +{"name":"@buzz/beehive","version":"0.0.1","private":true,"type":"module","engines":{"node":">=22.18"},"scripts":{"check":"tsc --noEmit","test":"node --test test/*.test.ts","start":"node src/cli.ts"},"dependencies":{"@noble/curves":"1.2.0","nostr-tools":"2.23.1","ws":"8.21.0"},"devDependencies":{"@types/node":"22.18.0","@types/ws":"8.18.1","typescript":"5.9.3"}} diff --git a/beehive/pnpm-lock.yaml b/beehive/pnpm-lock.yaml index 54a122f817..1e9b93d94d 100644 --- a/beehive/pnpm-lock.yaml +++ b/beehive/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@noble/curves': specifier: 1.2.0 version: 1.2.0 + nostr-tools: + specifier: 2.23.1 + version: 2.23.1(typescript@5.9.3) ws: specifier: 8.21.0 version: 8.21.0 @@ -27,19 +30,51 @@ importers: packages: + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@types/node@22.18.0': resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + nostr-tools@2.23.1: + resolution: {integrity: sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -62,12 +97,33 @@ packages: snapshots: + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/hashes@1.3.2': {} + '@noble/hashes@2.0.1': {} + + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 @@ -76,6 +132,20 @@ snapshots: dependencies: '@types/node': 22.18.0 + nostr-tools@2.23.1(typescript@5.9.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + typescript@5.9.3: {} undici-types@6.21.0: {} diff --git a/beehive/src/host-attestation.ts b/beehive/src/host-attestation.ts new file mode 100644 index 0000000000..1f5bfc7d10 --- /dev/null +++ b/beehive/src/host-attestation.ts @@ -0,0 +1,34 @@ +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. */ +export type HostAttestation = ['auth', string, string, string]; +function clauses(conditions: string): string[] { + if (conditions.length > 1024) throw Error('Attestation conditions too long'); + if (!conditions) return []; + const values = conditions.split('&'); + for (const clause of values) { + const match = /^(kind=|created_at<|created_at>)(0|[1-9][0-9]*)$/.exec(clause); + if (!match || Number(match[2]) > (match[1] === 'kind=' ? 65535 : 4294967295)) throw Error('Invalid attestation conditions'); + } + return values; +} +/** Explicit owner-side signing primitive; callers must obtain the existing owner signer + * interactively. It is not a host bootstrap and must never persist owner secret material. + */ +export function attestHost(ownerSecret: string, host: string, conditions: string): HostAttestation { + clauses(conditions); + if (!/^[0-9a-f]{64}$/.test(host) || host === publicKey(ownerSecret)) throw Error('Invalid independent host key'); + return ['auth', publicKey(ownerSecret), conditions, Buffer.from(schnorr.sign(digest(`nostr:agent-auth:${host}:${conditions}`), ownerSecret)).toString('hex')]; +} +/** Verify AUTH membership semantics: kind= is deliberately NOT evaluated against 22242, + * matching verify_auth_tag_for_auth_event. This is not operation/Move authorization. + */ +export function verifyHostAttestation(value: unknown, host: string, owner: string, now: number): HostAttestation { + if (!Array.isArray(value) || value.length !== 4 || value[0] !== 'auth' || value[1] !== owner || typeof value[2] !== 'string' || typeof value[3] !== 'string' || !/^[0-9a-f]{128}$/.test(value[3]) || !/^[0-9a-f]{64}$/.test(host) || !/^[0-9a-f]{64}$/.test(owner) || owner === host) throw Error('Invalid host attestation'); + for (const clause of clauses(value[2])) { + if ((clause.startsWith('created_at<') && !(now < Number(clause.slice(11)))) || (clause.startsWith('created_at>') && !(now > Number(clause.slice(11))))) throw Error('Expired/not-yet-valid host attestation'); + } + if (!schnorr.verify(value[3], digest(`nostr:agent-auth:${host}:${value[2]}`), owner)) throw Error('Invalid owner signature'); + return value as HostAttestation; +} diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts new file mode 100644 index 0000000000..fa466d3cbf --- /dev/null +++ b/beehive/src/host-identity.ts @@ -0,0 +1,45 @@ +import { existsSync, mkdirSync, rmdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fields, newKey, object, publicKey, text } from './protocol.ts'; +import { readPrivate, writePrivate } from './storage.ts'; +import { verifyHostAttestation, type HostAttestation } from './host-attestation.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 }; +function locked(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'); + 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 }; + writePrivate(join(directory, 'host-identity.json'), identity, true); + return identity; + }); +} +/** Read local identity without converting absent/expired enrollment into trust. */ +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; +} +/** 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 { + locked(directory, () => { + const identity = readHostIdentity(directory); + identity.attestation = verifyHostAttestation(attestation, publicKey(identity.secret), identity.owner, 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); +} diff --git a/beehive/src/nostr-client.ts b/beehive/src/nostr-client.ts new file mode 100644 index 0000000000..98268d49b0 --- /dev/null +++ b/beehive/src/nostr-client.ts @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto'; +import { WebSocket } from 'ws'; +import { finalizeEvent, getPublicKey, type Event } from 'nostr-tools/pure'; +import { unwrapManagement, wrapManagement, type AuthenticatedMessage } from './nostr-codec.ts'; +import type { Message } from './protocol.ts'; + +/** One authenticated subscription generation. Caller owns durable retries and operation + * completion; accepted publication is ONLY a relay ACK. No implicit effect retries. + * A new instance resubscribes full history, deliberately overlapping live delivery. + */ +export function connectNostr(url: string, secret: Uint8Array, authTag: string[] | undefined, receive: (value: AuthenticatedMessage) => void, disconnected: (error: Error) => void) { + const endpoint = new URL(url); + if (endpoint.username || endpoint.password || endpoint.hash || !(endpoint.protocol === 'wss:' || (endpoint.protocol === 'ws:' && endpoint.hostname === '127.0.0.1'))) throw Error('Nostr transport requires wss (loopback ws fixtures only)'); + const socket = new WebSocket(url, { maxPayload: 300000, handshakeTimeout: 2000 }); + const subscription = randomUUID(); + let ended = false; let subscribed = false; let requested = false; let authentication: string | undefined; + let challenges = 0; + let resolveReady!: () => void; let rejectReady!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + const pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + let startup = setTimeout(() => fail(Error('Nostr authentication/subscription timeout')), 5000); + function fail(error: Error) { + if (ended) return; + ended = true; subscribed = false; clearTimeout(startup); rejectReady(error); + for (const item of pending.values()) { clearTimeout(item.timer); item.reject(error); } + pending.clear(); socket.close(); disconnected(error); + } + function send(frame: unknown[]) { + if (ended || socket.readyState !== WebSocket.OPEN || socket.bufferedAmount > 1048576) throw Error('Nostr disconnected/backpressured; result unknown'); + socket.send(JSON.stringify(frame), error => { if (error) fail(error); }); + } + socket.on('error', fail); + socket.on('close', () => fail(Error('Nostr closed; unreceipted operations remain unknown'))); + socket.on('message', bytes => { + if (ended) return; + let frame: unknown; + try { frame = JSON.parse(bytes.toString()); } catch { fail(Error('Malformed relay frame')); return; } + if (!Array.isArray(frame)) { fail(Error('Malformed relay frame')); return; } + const [type, id, payload] = frame; + if (type === 'AUTH') { + if (typeof id !== 'string' || id.length > 1024 || ++challenges > 4) { fail(Error('Invalid/excessive AUTH challenges')); return; } + subscribed = false; requested = false; + clearTimeout(startup); + startup = setTimeout(() => fail(Error('Nostr authentication/subscription timeout')), 5000); + const event = finalizeEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['relay', url], ['challenge', id], ...(authTag ? [authTag] : [])] }, secret); + authentication = event.id; + try { send(['AUTH', event]); } catch (error) { fail(error as Error); } + } else if (type === 'OK' && typeof id === 'string') { + if (id === authentication) { + if (payload !== true) { fail(Error('Relay authentication denied')); return; } + authentication = undefined; + try { requested = true; send(['REQ', subscription, { kinds: [1059], '#p': [getPublicKey(secret)] }]); } catch (error) { fail(error as Error); } + } else { + const item = pending.get(id); + if (item) { clearTimeout(item.timer); pending.delete(id); payload === true ? item.resolve() : item.reject(Error('Relay rejected publication')); } + } + } else if (type === 'EOSE' && id === subscription && requested) { + subscribed = true; clearTimeout(startup); resolveReady(); + } else if (type === 'CLOSED' && id === subscription) { + fail(Error('Relay closed subscription')); + } else if (type === 'EVENT' && id === subscription && requested) { + let decoded: AuthenticatedMessage; + try { decoded = unwrapManagement(payload, secret); } catch { return; } + // Persistence/authority failures are application failures, not malformed ciphertext. + receive(decoded); + } + }); + return { + ready, + /** Resolves on relay acceptance, never on host operation completion. */ + publish(message: Message, recipient: string): Promise { + if (!subscribed || ended) return Promise.reject(Error('Nostr not subscribed; result unknown')); + if (pending.size >= 64) return Promise.reject(Error('Nostr publication backlog full')); + let event: Event; + try { event = wrapManagement(message, secret, recipient); } catch (error) { return Promise.reject(error); } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(event.id); reject(Error('Relay ACK timeout; result unknown')); }, 5000); + pending.set(event.id, { resolve, reject, timer }); + try { send(['EVENT', event]); } catch (error) { clearTimeout(timer); pending.delete(event.id); reject(error); } + }); + }, + close() { fail(Error('Nostr client closed')); }, + }; +} diff --git a/beehive/src/nostr-codec.ts b/beehive/src/nostr-codec.ts new file mode 100644 index 0000000000..ba36d731cc --- /dev/null +++ b/beehive/src/nostr-codec.ts @@ -0,0 +1,49 @@ +import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent, type Event } from 'nostr-tools/pure'; +import { v2 } from 'nostr-tools/nip44'; +import { fields, object, parseMessage, type Message } from './protocol.ts'; + +const domain = 'beehive-management-v1'; +/** Authenticated inner message; never infer the sender from the ephemeral outer key. */ +export type AuthenticatedMessage = { sender: string; message: Message }; +/** Standard NIP-59 seal + wrap, using current outer time to meet Buzz's ±900s admission. + * NIP-59 timestamp randomization is deliberately omitted (privacy tradeoff). Stable + * operation identity remains Message.id, NOT the rumor, seal, or wrap event id. + */ +export function wrapManagement(message: Message, secret: Uint8Array, recipient: string, now = Math.floor(Date.now() / 1000)): Event { + const content = JSON.stringify(parseMessage(message)); + if (Buffer.byteLength(content) > 32768) throw Error('Management message exceeds bounded wire size'); + const rumor = { kind: 14, pubkey: getPublicKey(secret), created_at: now, tags: [['p', recipient], ['subject', domain]], content }; + const seal = finalizeEvent({ kind: 13, created_at: now, tags: [], content: v2.encrypt(JSON.stringify({ ...rumor, id: getEventHash(rumor) }), v2.utils.getConversationKey(secret, recipient)) }, secret); + const ephemeral = generateSecretKey(); + return finalizeEvent({ kind: 1059, created_at: now, tags: [['p', recipient]], content: v2.encrypt(JSON.stringify(seal), v2.utils.getConversationKey(ephemeral, recipient)) }, ephemeral); +} +function verified(value: unknown, kind: number): Event { + const e = object(value); + fields(e, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']); + if (e.kind !== kind || typeof e.content !== 'string' || Buffer.byteLength(e.content) > 262144 || !verifyEvent(e as Event)) throw Error('Invalid signed event'); + return e as Event; +} +/** Verify both signatures, recipient, rumor hash and seal/rumor author binding before returning plaintext. */ +export function unwrapManagement(value: unknown, secret: Uint8Array): AuthenticatedMessage { + const wrap = verified(value, 1059); + const recipient = getPublicKey(secret); + if (JSON.stringify(wrap.tags) !== JSON.stringify([['p', recipient]])) throw Error('Wrong recipient'); + const seal = verified(JSON.parse(v2.decrypt(wrap.content, v2.utils.getConversationKey(secret, wrap.pubkey))), 13); + if (seal.tags.length) throw Error('Unexpected seal tags'); + const rumor = object(JSON.parse(v2.decrypt(seal.content, v2.utils.getConversationKey(secret, seal.pubkey)))); + fields(rumor, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content']); + if (rumor.pubkey !== seal.pubkey || rumor.kind !== 14 || typeof rumor.content !== 'string' || Buffer.byteLength(rumor.content) > 32768 || JSON.stringify(rumor.tags) !== JSON.stringify([['p', recipient], ['subject', domain]]) || rumor.id !== getEventHash(rumor as unknown as Event)) throw Error('Invalid management rumor'); + return { sender: seal.pubkey, message: parseMessage(JSON.parse(rumor.content)) }; +} +/** Tracer authority boundary. Host-to-host Move traffic is intentionally denied until + * source authority + owner-attested host catalog is integrated with retained journals. + */ +export function authorizeManagement(input: AuthenticatedMessage, owner: string, hosts: Readonly>, role: { host: string } | 'owner'): Message { + const { sender, message } = input; + if (role === 'owner') { + if (!['inventory', 'receipt'].includes(message.type) || hosts[message.host] !== sender) throw Error('Untrusted host response'); + } else { + if (message.host !== role.host || sender !== owner || !['profile', 'inspect', 'save', 'start', 'restart', 'stop', 'move'].includes(message.type)) throw Error('Unauthorized owner command'); + } + return message; +} diff --git a/beehive/test/nostr-fixture.ts b/beehive/test/nostr-fixture.ts new file mode 100644 index 0000000000..319d4057de --- /dev/null +++ b/beehive/test/nostr-fixture.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import { WebSocketServer, type WebSocket } from 'ws'; +import { verifyEvent, type Event } from 'nostr-tools/pure'; +import { verifyHostAttestation } from '../src/host-attestation.ts'; + +/** Narrow executable model of Buzz 051c3a2 admission, NOT a deployed/Rust relay. + * auth.rs: signed NIP42 + OA ViaOwner membership; api/mod.rs AUTH time semantics. + * ingest.rs:1059 WebSocket-only, signature, ±900s, 256KB, MessagesWrite, + * 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) { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: 300000 }); + await new Promise(resolve => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('Missing fixture address'); + const url = `ws://127.0.0.1:${address.port}`; + const history: Event[] = []; + const connections = new Map(); + let independentWraps = 0; + server.on('connection', socket => { + const state: { pubkey?: string; subscription?: string } = {}; + connections.set(socket, state); + const challenge = randomUUID(); + socket.send(JSON.stringify(['AUTH', challenge])); + socket.on('close', () => connections.delete(socket)); + socket.on('message', bytes => { + const [type, value, filter] = JSON.parse(bytes.toString()); + const now = Math.floor(Date.now() / 1000); + 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); + 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'])); } + } else if (type === 'REQ') { + if (!state.pubkey || JSON.stringify(filter) !== JSON.stringify({ kinds: [1059], '#p': [state.pubkey] })) { + socket.send(JSON.stringify(['CLOSED', value, 'restricted: p gate'])); return; + } + state.subscription = value; + for (const event of history) if (event.tags.some(t => t[0] === 'p' && t[1] === state.pubkey)) socket.send(JSON.stringify(['EVENT', value, event])); + socket.send(JSON.stringify(['EOSE', value])); + } else if (type === 'EVENT') { + const event = value as Event; + const accepted = Boolean(state.pubkey && verifyEvent(event) && event.kind === 1059 && Math.abs(event.created_at - now) <= 900 && Buffer.byteLength(event.content) <= 262144); + socket.send(JSON.stringify(['OK', event.id, accepted, accepted ? 'accepted' : 'invalid'])); + if (!accepted) return; + if (event.pubkey !== state.pubkey) independentWraps++; + if (history.some(e => e.id === event.id)) return; + history.push(event); + for (const [peer, target] of connections) if (target.subscription && event.tags.some(t => t[0] === 'p' && t[1] === target.pubkey)) peer.send(JSON.stringify(['EVENT', target.subscription, event])); + } + }); + }); + return { url, history, get independentWraps() { return independentWraps; }, async close() { for (const peer of connections.keys()) peer.terminate(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } }; +} diff --git a/beehive/test/nostr-transport.test.ts b/beehive/test/nostr-transport.test.ts new file mode 100644 index 0000000000..e28b9e97d6 --- /dev/null +++ b/beehive/test/nostr-transport.test.ts @@ -0,0 +1,158 @@ +import test from 'node:test'; +import { WebSocket } from 'ws'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, statSync, mkdirSync, rmdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generateSecretKey, getPublicKey, finalizeEvent, getEventHash } from 'nostr-tools/pure'; +import { unwrapEvent } from 'nostr-tools/nip59'; +import { v2 } from 'nostr-tools/nip44'; +import { message } from '../src/protocol.ts'; +import { wrapManagement, unwrapManagement, authorizeManagement, type AuthenticatedMessage } from '../src/nostr-codec.ts'; +import { attestHost, verifyHostAttestation } from '../src/host-attestation.ts'; +import { bootstrapHostIdentity, enrollHostIdentity, readHostIdentity, requireHostEnrollment } from '../src/host-identity.ts'; +import { connectNostr } from '../src/nostr-client.ts'; +import { nostrFixture } from './nostr-fixture.ts'; + +const hex = (key: Uint8Array) => Buffer.from(key).toString('hex'); +function mailbox() { + const values: AuthenticatedMessage[] = []; + let resolve: (() => void) | undefined; + return { values, receive(value: AuthenticatedMessage) { values.push(value); resolve?.(); }, async count(n: number) { + while (values.length < n) await new Promise((done, reject) => { + const timer = setTimeout(() => reject(Error('Message delivery timed out')), 2000); + resolve = () => { clearTimeout(timer); done(); }; + }); + } }; +} + +test('NIP59 standard interop, tamper/wrong recipient, stable inner operation on fresh wraps', () => { + const owner = generateSecretKey(), host = generateSecretKey(); + const operation = message('start', 'desktop', 'agent'); + const first = wrapManagement(operation, owner, getPublicKey(host)); + const second = wrapManagement(operation, owner, getPublicKey(host)); + assert.notEqual(first.id, second.id); + assert.notEqual(first.pubkey, getPublicKey(owner)); + assert.equal(JSON.parse(unwrapEvent(first, host).content).id, operation.id); + assert.deepEqual(unwrapManagement(first, host), { sender: getPublicKey(owner), message: operation }); + assert.deepEqual(unwrapManagement(second, host).message, operation); + assert.throws(() => unwrapManagement(first, generateSecretKey())); + assert.throws(() => unwrapManagement({ ...first, content: first.content.slice(0, -4) + 'AAAA' }, host)); + // Valid outer/seal signatures do not permit a different rumor author. + const rumor = { kind: 14, pubkey: getPublicKey(host), created_at: first.created_at, content: JSON.stringify(operation), tags: [['p', getPublicKey(host)], ['subject', 'beehive-management-v1']] }; + const seal = finalizeEvent({ kind: 13, created_at: first.created_at, tags: [], content: v2.encrypt(JSON.stringify({ ...rumor, id: getEventHash(rumor) }), v2.utils.getConversationKey(owner, getPublicKey(host))) }, owner); + const outer = generateSecretKey(); + const forged = finalizeEvent({ kind: 1059, created_at: first.created_at, tags: [['p', getPublicKey(host)]], content: v2.encrypt(JSON.stringify(seal), v2.utils.getConversationKey(outer, getPublicKey(host))) }, outer); + assert.throws(() => unwrapManagement(forged, host), /rumor/); +}); + +test('independent enrolled hosts cannot forge owner, other host, or Move grant', () => { + const owner = getPublicKey(generateSecretKey()), a = getPublicKey(generateSecretKey()), b = getPublicKey(generateSecretKey()); + const hosts = { desktop: a, laptop: b }; + assert.throws(() => authorizeManagement({ sender: a, message: message('start', 'laptop', 'agent') }, owner, hosts, { host: 'laptop' })); + assert.throws(() => authorizeManagement({ sender: a, message: message('inventory', 'laptop', 'agent') }, owner, hosts, 'owner')); + assert.throws(() => authorizeManagement({ sender: a, message: message('grant', 'laptop', 'agent') }, owner, hosts, { host: 'laptop' })); + assert.throws(() => authorizeManagement({ sender: owner, message: message('start', 'desktop', 'agent') }, owner, hosts, { host: 'laptop' })); +}); + +test('NIP-OA SDK spec vector and AUTH time vs kind semantics', () => { + const owner = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const host = 'c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5'; + const tag = ['auth', owner, 'kind=1&created_at<1713957000', '8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369']; + assert.deepEqual(verifyHostAttestation(tag, host, owner, 1713956999), tag); + assert.throws(() => verifyHostAttestation(tag, host, owner, 1713957000)); + assert.throws(() => verifyHostAttestation(tag, getPublicKey(generateSecretKey()), owner, 1713956999)); +}); + +test('durable owner-public-only pending bootstrap, genuine enrollment and installation exclusion', () => { + const directory = mkdtempSync(join(tmpdir(), 'beehive-host-key-')); + try { + const owner = generateSecretKey(); + const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner)); + assert.equal(identity.attestation, 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)); + 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); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test('real Nostr wire fixture: independent owner + two hosts inventory/command/receipt, history reopen and freshwrap dedup', async () => { + const owner = generateSecretKey(), desktop = generateSecretKey(), laptop = generateSecretKey(); + const ownerPub = getPublicKey(owner), hosts = { desktop: getPublicKey(desktop), laptop: getPublicKey(laptop) }; + const fixture = await nostrFixture(ownerPub); + const inbox = mailbox(), desktopInbox = mailbox(), laptopInbox = mailbox(); + const clients: ReturnType[] = []; + const connect = (secret: Uint8Array, box: ReturnType) => { + const client = connectNostr(fixture.url, secret, secret === owner ? undefined : attestHost(hex(owner), getPublicKey(secret), 'kind=1059'), box.receive, () => {}); + clients.push(client); return client; + }; + try { + const ui = connect(owner, inbox), a = connect(desktop, desktopInbox), b = connect(laptop, laptopInbox); + await Promise.all([ui.ready, a.ready, b.ready]); + await a.publish(message('inventory', 'desktop', 'agent'), ownerPub); + await b.publish(message('inventory', 'laptop', 'agent'), ownerPub); + await inbox.count(2); + for (const input of inbox.values) assert.equal(authorizeManagement(input, ownerPub, hosts, 'owner').type, 'inventory'); + const operation = message('start', 'desktop', 'agent'); + await ui.publish(operation, hosts.desktop); + await desktopInbox.count(1); + assert.equal(laptopInbox.values.length, 0); + assert.deepEqual(authorizeManagement(desktopInbox.values[0], ownerPub, hosts, { host: 'desktop' }), operation); + assert.equal(inbox.values.length, 2, 'relay ACK is not a host receipt'); + await a.publish(message('receipt', 'desktop', 'agent', 0, { operation: operation.id }), ownerPub); + await inbox.count(3); + assert.equal(authorizeManagement(inbox.values[2], ownerPub, hosts, 'owner').body.operation, operation.id); + a.close(); + const recovered = mailbox(); const reopen = connect(desktop, recovered); await reopen.ready; + await recovered.count(1); + await ui.publish(operation, hosts.desktop); await recovered.count(2); + assert.notEqual(fixture.history[2].id, fixture.history.at(-1)?.id); + assert.equal(new Set(recovered.values.map(v => v.message.id)).size, 1, 'durable consumer must dedup same inner id (not lifecycle proof)'); + assert.ok(fixture.independentWraps >= 5); + const denied = connectNostr(fixture.url, generateSecretKey(), undefined, () => {}, () => {}); clients.push(denied); + await assert.rejects(denied.ready, /authentication denied/); + } finally { for (const client of clients) client.close(); await fixture.close(); } +}); + + +test('fixture enforces signed AUTH, p-gated reads, supported kind, timestamp and signature admission', async () => { + const owner = generateSecretKey(); + const fixture = await nostrFixture(getPublicKey(owner)); + const socket = new WebSocket(fixture.url); + const frames: unknown[][] = []; + let wake: (() => void) | undefined; + socket.on('message', data => { frames.push(JSON.parse(data.toString())); wake?.(); }); + async function next(): Promise { + while (!frames.length) await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('Missing fixture response')), 2000); + wake = () => { clearTimeout(timer); resolve(); }; + }); + return frames.shift()!; + } + const send = (frame: unknown[]) => socket.send(JSON.stringify(frame)); + try { + const challenge = await next(); + const now = Math.floor(Date.now() / 1000); + const auth = finalizeEvent({ kind: 22242, created_at: now, content: '', tags: [['relay', fixture.url], ['challenge', String(challenge[1])]] }, owner); + send(['AUTH', { ...auth, sig: '0'.repeat(128) }]); assert.equal((await next())[2], false); + send(['AUTH', auth]); assert.equal((await next())[2], true); + send(['REQ', 'foreign', { kinds: [1059], '#p': [getPublicKey(generateSecretKey())] }]); assert.equal((await next())[0], 'CLOSED'); + send(['REQ', 'bare', { kinds: [1059] }]); assert.equal((await next())[0], 'CLOSED'); + const old = wrapManagement(message('start', 'desktop', 'agent'), owner, getPublicKey(generateSecretKey()), now - 901); + send(['EVENT', old]); assert.equal((await next())[2], false); + const unsupported = finalizeEvent({ kind: 30179, created_at: now, content: '', tags: [] }, owner); + send(['EVENT', unsupported]); assert.equal((await next())[2], false); + const current = wrapManagement(message('start', 'desktop', 'agent'), owner, getPublicKey(generateSecretKey())); + send(['EVENT', { ...current, sig: '0'.repeat(128) }]); assert.equal((await next())[2], false); + send(['EVENT', current]); assert.equal((await next())[2], true); + } finally { socket.close(); await fixture.close(); } +});