mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(beehive): checkpoint independent-identity Nostr management tracer
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
141
beehive/REAL_HOST_TRANSPORT_473233B8.md
Normal file
141
beehive/REAL_HOST_TRANSPORT_473233B8.md
Normal 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.
|
||||
@@ -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
70
beehive/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
34
beehive/src/host-attestation.ts
Normal file
34
beehive/src/host-attestation.ts
Normal 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;
|
||||
}
|
||||
45
beehive/src/host-identity.ts
Normal file
45
beehive/src/host-identity.ts
Normal 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);
|
||||
}
|
||||
84
beehive/src/nostr-client.ts
Normal file
84
beehive/src/nostr-client.ts
Normal 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')); },
|
||||
};
|
||||
}
|
||||
49
beehive/src/nostr-codec.ts
Normal file
49
beehive/src/nostr-codec.ts
Normal 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;
|
||||
}
|
||||
58
beehive/test/nostr-fixture.ts
Normal file
58
beehive/test/nostr-fixture.ts
Normal 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())); } };
|
||||
}
|
||||
158
beehive/test/nostr-transport.test.ts
Normal file
158
beehive/test/nostr-transport.test.ts
Normal 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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user