feat(beehive): checkpoint independent-identity Nostr management tracer

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 12:26:10 -04:00
parent 419370d38c
commit a8e2424ee9
9 changed files with 640 additions and 1 deletions

View File

@@ -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.

View File

@@ -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"}}

70
beehive/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

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

View File

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

View File

@@ -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<void>((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
const pending = new Map<string, { resolve: () => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
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<void> {
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<void>((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')); },
};
}

View File

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

View File

@@ -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<void>(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<WebSocket, { pubkey?: string; subscription?: string }>();
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<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } };
}

View File

@@ -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<void>((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<typeof connectNostr>[] = [];
const connect = (secret: Uint8Array, box: ReturnType<typeof mailbox>) => {
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<unknown[]> {
while (!frames.length) await new Promise<void>((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(); }
});