From ac781fd95366725a18787efc231bb747c8886fe5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 14:29:45 -0400 Subject: [PATCH] beehive: verify direct relay membership over the relay HTTP contract NIP-11 requirement discovery, host-keyed NIP-98 invite claim and membership-gated /query evidence against the relay-signed kind 13534 roster, per the verified contract at block/buzz 051c3a2. Signer output is re-verified before any request leaves; redirects, timeouts and aborts are bounded; invite material never surfaces in errors. Signed-off-by: Logan Johnson --- beehive/src/direct-membership.ts | 398 +++++++++++++++++++++++++ beehive/test/direct-membership.test.ts | 365 +++++++++++++++++++++++ 2 files changed, 763 insertions(+) create mode 100644 beehive/src/direct-membership.ts create mode 100644 beehive/test/direct-membership.test.ts diff --git a/beehive/src/direct-membership.ts b/beehive/src/direct-membership.ts new file mode 100644 index 0000000000..a622a86a0a --- /dev/null +++ b/beehive/src/direct-membership.ts @@ -0,0 +1,398 @@ +import { verifyEvent, type Event } from 'nostr-tools/pure'; +import { digest, fields, object, text } from './protocol.ts'; + +/** Direct relay membership for a beehive host against an existing Buzz relay: + * NIP-11 requirement discovery, host-signed invite claim (NIP-98 HTTP auth), + * and direct-membership evidence verification against the relay's own signed + * kind 13534 roster. Interfaces follow the relay contract at block/buzz + * 051c3a270be9c73da9ab06700bcab7d5552fceaa (api/invites.rs, api/bridge.rs, + * api/mod.rs, nip11.rs); source verification is not deployed proof, so every + * check runs against the deployment origin supplied by the caller. + * + * Deliberate fences: + * - The host signer is an injected async boundary; this module never reads a + * keychain, never mints invite material, and never accepts or produces + * owner NIP-OA/OAuth delegation. + * - Host registration, agent placement and per-agent authorization are + * separate concerns and absent here; the roster is the relay's existing + * pubkey/role contract only. + * - No redirect is ever followed: authorization and invite material must not + * cross a redirect or origin change. Every request has a bounded timeout. + * + * Proof semantics for the integrator: + * - `liveAdmission: 'verified'` is a fresh per-request row check: POST /query + * runs the relay-membership gate on every request (query_events_authed), + * so it is this module's equivalent of the no-OA NIP-42 AUTH live check + * without duplicating the WebSocket runtime. It is point-in-time, not + * continuous: an already-authenticated WebSocket elsewhere retains its + * admission scope until it closes, and revocation is only observed by + * re-verification. It says nothing about roles or agent authorization. + * - `roster.status: 'observed'` is the relay-signed kind 13534 snapshot of + * `relay_members` as of its `created_at`. Publication is best-effort after a + * committed row change and may lag or fail; `already_member` claims do not + * republish it. Old valid rosters are accepted (no freshness gate), and + * roster absence never revokes committed or live evidence. + * - A committed claim response is the relay's transactional readback of the + * inserted row, not a publish ACK. None of these establish host + * registration, normal enrollment completion or production admission. + */ + +/** Bounded HTTP transport boundary. Implementations MUST NOT follow redirects + * (any 3xx is refused here as well) and must honor the per-request signal. */ +export type RelayHttpTransport = (request: { + url: string; + method: 'GET' | 'POST'; + headers: Record; + body: string | undefined; + timeoutMs: number; + signal: AbortSignal; +}) => Promise<{ status: number; bodyText: string }>; + +/** Async host-key boundary. Implementations own key access and must produce a + * fresh kind 27235 event per request (the relay rejects replayed event ids) + * covering exactly the given url/method/payload hash. */ +export type HostAuthorizationSigner = { + readonly hostPublicKey: string; + signHttpAuthentication(request: { url: string; method: 'POST'; payloadSha256Hex: string }): Promise; +}; + +/** NIP-11 relay membership requirements. `membershipEnforced` is true only + * when the deployment advertises NIP-43 together with a usable `self` key. */ +export type RelayMembershipRequirements = { + readonly origin: string; + readonly relaySelf: string | null; + readonly membershipEnforced: boolean; + readonly supportedNips: readonly number[]; +}; + +/** Committed invite-claim readback. The operator issued the invite; this + * module never mints one and never logs or echoes the code. */ +export type InviteClaimOutcome = { + readonly status: 'joined' | 'already_member'; + readonly communityId: string; + readonly host: string; + readonly role: string; + readonly claimedAt: number; +}; + +/** Relay-signed roster observation for the host key. */ +export type RosterObservation = { + readonly status: 'observed' | 'absent' | 'unavailable' | 'not-checked'; + readonly role?: string; + readonly createdAt?: number; + /** Claim committed but the roster does not list the host: publication is + * best-effort after the commit, so it may lag or have failed. */ + readonly lagging: boolean; +}; + +/** Truthful observation, never a permanent approved boolean. Precedence: + * denied > live-verified-member > roster-member > claim-committed > unknown. */ +export type DirectMembershipEvidence = { + readonly relay: string; + readonly origin: string; + readonly hostPublicKey: string; + readonly discovery: 'observed' | 'unavailable'; + readonly relaySelf: string | null; + readonly membershipEnforced: boolean; + readonly claim: InviteClaimOutcome | null; + readonly liveAdmission: 'verified' | 'denied' | 'unavailable' | 'not-checked'; + readonly roster: RosterObservation; + readonly status: 'live-verified-member' | 'roster-member' | 'claim-committed' | 'denied' | 'unknown'; +}; + +const NIP98_KIND = 27235; +const NIP98_TOLERANCE_SECONDS = 60; +const ROSTER_KIND = 13534; +const MEMBERSHIP_NIP = 43; +const DEFAULT_TIMEOUT_MS = 5000; +const MAX_BODY_BYTES = 262144; + +/** Default platform-fetch adapter: redirects are refused, no credentials or + * proxies are configured, and response bodies are size-bounded. It is the + * only place this module touches the network. */ +export function fetchRelayTransport(fetcher: typeof globalThis.fetch = globalThis.fetch.bind(globalThis)): RelayHttpTransport { + return async request => { + const response = await fetcher(request.url, { method: request.method, headers: request.headers, body: request.body, redirect: 'error', signal: request.signal }); + const declared = Number(response.headers.get('content-length')); + if (declared > MAX_BODY_BYTES) throw Error('Relay response exceeds bounded size'); + const bodyText = await response.text(); + if (bodyText.length > MAX_BODY_BYTES) throw Error('Relay response exceeds bounded size'); + return { status: response.status, bodyText }; + }; +} + +/** Normal HTTP origin of a relay URL: wss→https, https→https; ws/http only + * for 127.0.0.1 loopback fixtures, matching the package transport rules. */ +export function relayHttpOrigin(relay: string): URL { + const url = new URL(relay); + if (url.username || url.password || url.search || url.hash || (url.pathname !== '' && url.pathname !== '/')) throw Error('Relay origin URL expected'); + const secure = url.protocol === 'wss:' || url.protocol === 'https:'; + const loopback = (url.protocol === 'ws:' || url.protocol === 'http:') && url.hostname === '127.0.0.1'; + if (!secure && !loopback) throw Error('Relay requires wss/https (loopback ws/http fixtures only)'); + return new URL(`${secure ? 'https' : 'http'}://${url.host}/`); +} + +function boundedTimeout(value: number | undefined): number { + const timeoutMs = value ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) throw Error('Invalid relay request timeout'); + return timeoutMs; +} + +function hexPublicKey(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) throw Error(`Invalid ${label}`); + return value; +} + +function requestScope(timeoutMs: number, external?: AbortSignal) { + const controller = new AbortController(); + const abort = (message: string) => { if (!controller.signal.aborted) controller.abort(Error(message)); }; + const timer = setTimeout(() => abort('Relay HTTP request timeout'), timeoutMs); + const cancel = () => abort('Relay HTTP request cancelled'); + if (external?.aborted) cancel(); + else external?.addEventListener('abort', cancel, { once: true }); + return { signal: controller.signal, done: () => { clearTimeout(timer); external?.removeEventListener('abort', cancel); } }; +} + +type RelayRequest = { transport: RelayHttpTransport; url: string; method: 'GET' | 'POST'; headers: Record; body: string | undefined; timeoutMs: number; signal?: AbortSignal }; + +/** One bounded request. The transport race is abandoned (not reported twice) + * when the scope aborts; redirects are refused before any body is parsed. */ +async function relayFetch(request: RelayRequest): Promise<{ status: number; bodyText: string }> { + const scope = requestScope(request.timeoutMs, request.signal); + const attempt = request.transport({ url: request.url, method: request.method, headers: request.headers, body: request.body, timeoutMs: request.timeoutMs, signal: scope.signal }); + void attempt.catch(() => {}); + try { + const response = await Promise.race([attempt, new Promise((_, reject) => { + // An abort that landed while the transport was being constructed (or a + // pre-aborted caller signal) has already fired the signal's abort event; + // a late listener would never fire and the race would hang, so reject + // immediately instead. + if (scope.signal.aborted) reject(scope.signal.reason); + else scope.signal.addEventListener('abort', () => reject(scope.signal.reason), { once: true }); + })]); + if (response.status >= 300 && response.status < 400) throw Error('Relay redirect refused; authorization and invite material not forwarded'); + if (!Number.isSafeInteger(response.status) || response.status < 200 || response.status > 599 || typeof response.bodyText !== 'string' || response.bodyText.length > MAX_BODY_BYTES) throw Error('Malformed relay transport response'); + return response; + } finally { scope.done(); } +} + +/** Only module-owned, code-free reasons are surfaced; third-party transport + * error text stays out of messages because it may echo request material. */ +function safeReason(error: unknown): string { + return error instanceof Error && /^(Relay HTTP request timeout|Relay HTTP request cancelled|Relay redirect refused)/.test(error.message) ? error.message : 'transport failure'; +} + +/** Bounded, control-character-free relay error field only; never a raw body. + * An excerpt that echoes supplied request material (a hostile relay echoing + * the invite code back) is coarsened rather than surfaced. */ +function relayErrorExcerpt(bodyText: string, secret?: string): string { + try { + const value = object(JSON.parse(bodyText)); + if (typeof value.error === 'string' && value.error.length > 0 && value.error.length <= 128 && !/[\x00-\x1f\x7f]/.test(value.error)) { + return secret !== undefined && value.error.includes(secret) ? 'unspecified relay error' : value.error; + } + } catch { /* unparseable relay error bodies stay coarse */ } + return 'unspecified relay error'; +} + +/** Strictly typed Nostr event parse; rejects unknown/missing fields. */ +function parseEvent(value: unknown): Event { + const e = object(value); + fields(e, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']); + if (typeof e.id !== 'string' || !/^[0-9a-f]{64}$/.test(e.id) || typeof e.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(e.pubkey)) throw Error('Invalid event identity'); + if (typeof e.sig !== 'string' || !/^[0-9a-f]{128}$/.test(e.sig)) throw Error('Invalid event signature'); + if (typeof e.content !== 'string' || e.content.length > 4096) throw Error('Invalid event content'); + if (!Number.isSafeInteger(e.created_at) || Number(e.created_at) < 0 || !Number.isSafeInteger(e.kind)) throw Error('Invalid event metadata'); + if (!Array.isArray(e.tags) || e.tags.length > 512) throw Error('Invalid event tags'); + for (const tag of e.tags) { + if (!Array.isArray(tag) || tag.length < 1 || tag.length > 16) throw Error('Invalid event tag'); + for (const item of tag) if (typeof item !== 'string' || item.length > 1024) throw Error('Invalid event tag value'); + } + return { id: e.id, pubkey: e.pubkey, created_at: Number(e.created_at), kind: Number(e.kind), tags: e.tags as string[][], content: e.content, sig: e.sig }; +} + +function tagValues(event: Event, name: string): string[] { + return event.tags.filter(tag => tag[0] === name && tag.length >= 2).map(tag => tag[1]); +} + +/** Signer output is fully re-verified before any request leaves the module, + * binding the signature to the exact host identity, URL, method and payload. */ +async function nip98Authorization(signer: HostAuthorizationSigner, url: string, body: string, now: number): Promise { + const hostPublicKey = hexPublicKey(signer.hostPublicKey, 'host public key'); + const payloadSha256Hex = digest(body).toString('hex'); + const serialized = await signer.signHttpAuthentication({ url, method: 'POST', payloadSha256Hex }); + let event: Event; + try { event = parseEvent(JSON.parse(serialized)); } catch { throw Error('Signer returned a malformed NIP-98 event'); } + if (event.kind !== NIP98_KIND) throw Error('Signer returned a non-NIP-98 event kind'); + if (event.pubkey !== hostPublicKey) throw Error('NIP-98 signer key does not match the specified host identity'); + const [u] = tagValues(event, 'u'); + if (tagValues(event, 'u').length !== 1 || u !== url) throw Error('NIP-98 event is not bound to this relay request URL'); + const [method] = tagValues(event, 'method'); + if (tagValues(event, 'method').length !== 1 || method?.toUpperCase() !== 'POST') throw Error('NIP-98 event does not cover this request method'); + const [payload] = tagValues(event, 'payload'); + if (tagValues(event, 'payload').length !== 1 || payload !== payloadSha256Hex) throw Error('NIP-98 event does not cover the request payload'); + if (Math.abs(event.created_at - now) > NIP98_TOLERANCE_SECONDS) throw Error('NIP-98 event timestamp is outside the relay acceptance window'); + if (!verifyEvent(event)) throw Error('NIP-98 event signature is invalid'); + return `Nostr ${Buffer.from(serialized, 'utf8').toString('base64')}`; +} + +function parseClaimResponse(value: unknown): { status: 'joined' | 'already_member'; communityId: string; host: string; role: string } { + const v = object(value); + fields(v, ['status', 'community_id', 'host', 'role']); + if (v.status !== 'joined' && v.status !== 'already_member') throw Error('Invalid claim status'); + return { status: v.status, communityId: text(v.community_id, 128), host: text(v.host, 256), role: text(v.role, 64) }; +} + +/** Discover relay membership requirements from the deployment's NIP-11 + * document (GET origin with `Accept: application/nostr+json`). NIP-43 is only + * trustworthy together with a `self` signing key; anything else is reported + * as not enforced and must never masquerade as verified membership. */ +export async function discoverRelayMembershipRequirements(input: { relay: string; transport?: RelayHttpTransport; timeoutMs?: number; signal?: AbortSignal }): Promise { + const origin = relayHttpOrigin(input.relay); + const transport = input.transport ?? fetchRelayTransport(); + const response = await relayFetch({ transport, url: origin.toString(), method: 'GET', headers: { accept: 'application/nostr+json' }, body: undefined, timeoutMs: boundedTimeout(input.timeoutMs), signal: input.signal }); + if (response.status !== 200) throw Error(`Relay information unavailable (HTTP ${response.status})`); + let document: Record; + try { document = object(JSON.parse(response.bodyText)); } catch { throw Error('Malformed relay information document'); } + const relaySelf = document.self === undefined || document.self === null ? null : hexPublicKey(document.self, 'relay self key'); + const supportedNips: number[] = []; + if (document.supported_nips !== undefined && document.supported_nips !== null) { + if (!Array.isArray(document.supported_nips) || document.supported_nips.length > 128) throw Error('Malformed relay supported_nips'); + for (const nip of document.supported_nips) { + if (!Number.isSafeInteger(nip) || Number(nip) < 0 || Number(nip) > 65535) throw Error('Malformed relay supported_nips'); + supportedNips.push(Number(nip)); + } + } + return { origin: origin.toString(), relaySelf, membershipEnforced: relaySelf !== null && supportedNips.includes(MEMBERSHIP_NIP), supportedNips }; +} + +/** Claim an operator-issued invite with the host's own key (POST + * /api/invites/claim, NIP-98 with a payload tag; deliberately outside the + * membership gate). Only the joining host key signs; no owner key is used or + * accepted. The invite code is request material: it is never logged, echoed, + * or embedded in any error this module produces. A transport failure after + * the request left is reported as an unknown commit outcome, never as a + * clean failure. */ +export async function claimRelayMembershipInvite(input: { + relay: string; + signer: HostAuthorizationSigner; + inviteCode: string; + policyReceipt?: string; + transport?: RelayHttpTransport; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +}): Promise { + const origin = relayHttpOrigin(input.relay); + hexPublicKey(input.signer.hostPublicKey, 'host public key'); + const inviteCode = text(input.inviteCode, 1024); + const policyReceipt = input.policyReceipt === undefined ? undefined : text(input.policyReceipt, 2048); + const now = input.now ?? Math.floor(Date.now() / 1000); + const transport = input.transport ?? fetchRelayTransport(); + const url = `${origin}api/invites/claim`; + const body = JSON.stringify(policyReceipt === undefined ? { code: inviteCode } : { code: inviteCode, policy_receipt: policyReceipt }); + const authorization = await nip98Authorization(input.signer, url, body, now); + let response: { status: number; bodyText: string }; + try { + response = await relayFetch({ transport, url, method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization }, body, timeoutMs: boundedTimeout(input.timeoutMs), signal: input.signal }); + } catch (error) { + throw Error(`Invite claim request failed before a relay outcome; commit status unknown (${safeReason(error)})`); + } + if (response.status !== 200) throw Error(`Invite claim rejected by the relay (HTTP ${response.status}: ${relayErrorExcerpt(response.bodyText, inviteCode)})`); + let outcome: ReturnType; + try { outcome = parseClaimResponse(JSON.parse(response.bodyText)); } catch { throw Error('Malformed invite claim response'); } + if (outcome.host !== origin.host) throw Error('Invite claim response is bound to a different relay host'); + return { status: outcome.status, communityId: outcome.communityId, host: outcome.host, role: outcome.role, claimedAt: now }; +} + +/** Verified roster observation for the host key. Only events signed by the + * NIP-11 `self` key count; unsigned lists and caller labels never do. */ +function rosterObservation(events: readonly unknown[], relaySelf: string, hostPublicKey: string, claimCommitted: boolean): RosterObservation { + let newest: Event | undefined; + for (const value of events) { + let event: Event; + try { event = parseEvent(value); } catch { continue; } + if (event.kind !== ROSTER_KIND || event.pubkey !== relaySelf || !verifyEvent(event)) continue; + if (!newest || event.created_at >= newest.created_at) newest = event; + } + if (!newest) return { status: 'unavailable', lagging: claimCommitted }; + const roles = new Set(); + try { + for (const tag of newest.tags) if (tag[0] === 'member' && tag.length >= 3 && tag[1] === hostPublicKey) roles.add(text(tag[2], 64)); + } catch { return { status: 'unavailable', lagging: claimCommitted }; } + if (roles.size === 0) return { status: 'absent', lagging: claimCommitted }; + if (roles.size > 1) return { status: 'unavailable', lagging: claimCommitted }; + return { status: 'observed', role: [...roles][0], createdAt: newest.created_at, lagging: false }; +} + +/** Gather direct-membership evidence for a host key against one relay: + * NIP-11 discovery, then a host-signed membership-gated POST /query read + * (the live per-request row check) whose response is also the roster source. + * Signer misuse throws; transport failures downgrade truthfully to + * 'unavailable'/'unknown' evidence instead of masquerading. */ +export async function verifyDirectMembershipEvidence(input: { + relay: string; + signer: HostAuthorizationSigner; + claim?: InviteClaimOutcome; + transport?: RelayHttpTransport; + timeoutMs?: number; + signal?: AbortSignal; + now?: number; +}): Promise { + const origin = relayHttpOrigin(input.relay); + const hostPublicKey = hexPublicKey(input.signer.hostPublicKey, 'host public key'); + const now = input.now ?? Math.floor(Date.now() / 1000); + const transport = input.transport ?? fetchRelayTransport(); + const timeoutMs = boundedTimeout(input.timeoutMs); + const claimInput = input.claim; + let claim: InviteClaimOutcome | null = null; + if (claimInput !== undefined) { + // Re-validate the caller-supplied readback through the wire-shape parser + // (same field/length rules as a fresh response) before treating it as + // evidence, and preserve its local commit timestamp. + if (!Number.isSafeInteger(claimInput.claimedAt) || claimInput.claimedAt < 0) throw Error('Invalid invite claim evidence'); + const parsed = parseClaimResponse({ status: claimInput.status, community_id: claimInput.communityId, host: claimInput.host, role: claimInput.role }); + claim = { status: parsed.status, communityId: parsed.communityId, host: parsed.host, role: parsed.role, claimedAt: claimInput.claimedAt }; + } + if (claim !== null && claim.host !== origin.host) throw Error('Invite claim evidence is bound to a different relay host'); + let discovery: 'observed' | 'unavailable' = 'unavailable'; + let relaySelf: string | null = null; + let membershipEnforced = false; + try { + const requirements = await discoverRelayMembershipRequirements({ relay: input.relay, transport, timeoutMs, signal: input.signal }); + if (requirements.origin !== origin.toString()) throw Error('Discovery origin mismatch'); + discovery = 'observed'; + relaySelf = requirements.relaySelf; + membershipEnforced = requirements.membershipEnforced; + } catch { /* unavailable discovery keeps later evidence unknown, never verified */ } + let liveAdmission: DirectMembershipEvidence['liveAdmission'] = 'not-checked'; + let roster: RosterObservation = { status: 'not-checked', lagging: false }; + if (relaySelf !== null) { + const url = `${origin}query`; + const body = JSON.stringify([{ kinds: [ROSTER_KIND], authors: [relaySelf] }]); + const authorization = await nip98Authorization(input.signer, url, body, now); + try { + const response = await relayFetch({ transport, url, method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization }, body, timeoutMs, signal: input.signal }); + if (response.status === 200) { + const events: unknown = JSON.parse(response.bodyText); + if (!Array.isArray(events) || events.length > 512) throw Error('Malformed relay query response'); + roster = rosterObservation(events, relaySelf, hostPublicKey, claim !== null); + // Only an enforced relay's gate makes a 200 a live row check; an open + // relay serves the same read to anyone and must not masquerade. + if (membershipEnforced) liveAdmission = 'verified'; + } else if (membershipEnforced && response.status === 403 && relayErrorExcerpt(response.bodyText) === 'relay_membership_required') { + liveAdmission = 'denied'; + roster = { status: 'unavailable', lagging: claim !== null }; + } else { + liveAdmission = 'unavailable'; + roster = { status: 'unavailable', lagging: claim !== null }; + } + } catch { liveAdmission = 'unavailable'; roster = { status: 'unavailable', lagging: claim !== null }; } + } + const status: DirectMembershipEvidence['status'] = liveAdmission === 'denied' ? 'denied' + : liveAdmission === 'verified' ? 'live-verified-member' + : roster.status === 'observed' ? 'roster-member' + : claim !== null ? 'claim-committed' + : 'unknown'; + return { relay: input.relay, origin: origin.toString(), hostPublicKey, discovery, relaySelf, membershipEnforced, claim, liveAdmission, roster, status }; +} diff --git a/beehive/test/direct-membership.test.ts b/beehive/test/direct-membership.test.ts new file mode 100644 index 0000000000..53bd13635c --- /dev/null +++ b/beehive/test/direct-membership.test.ts @@ -0,0 +1,365 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, type Event } from 'nostr-tools/pure'; +import { + claimRelayMembershipInvite, + discoverRelayMembershipRequirements, + fetchRelayTransport, + verifyDirectMembershipEvidence, + type HostAuthorizationSigner, + type RelayHttpTransport, +} from '../src/direct-membership.ts'; + +const NOW = 1_700_000_000; +const COMMUNITY_ID = '7e4e4d21-8d64-4c9a-9d3f-1b0f3d5a9c2e'; + +/** Narrow executable model of the relay's HTTP membership contract at block/buzz + * 051c3a270be9c73da9ab06700bcab7d5552fceaa (api/invites.rs, api/bridge.rs, + * api/mod.rs, store/relay_members.rs): NIP-11 discovery, NIP-98-signed POST + * /api/invites/claim outside the membership gate, membership-gated POST + * /query, and the kind 13534 roster ('-' marker plus ['member', pubkey, role] + * tags) signed by the relay self key. NOT the deployed Rust relay: no tenant + * DB, no HMAC invite oracle, no replay cache — fixtures grant fixture-policy + * member rows only. The module under test is the client. */ + +function rosterEvent(relayKey: Uint8Array, members: readonly (readonly [string, string])[], createdAt: number): Event { + return finalizeEvent({ kind: 13534, created_at: createdAt, tags: [['-'], ...members.map(([pubkey, role]) => ['member', pubkey, role])], content: '' }, relayKey); +} + +function hostSigner(secretKey: Uint8Array, options?: { + key?: Uint8Array; // sign with a key other than the declared host identity + now?: number; // created_at override + url?: string; // misbound u tag + payload?: string; // misbound payload hash + kind?: number; // wrong event kind + corrupt?: boolean; // well-formed event, invalid signature + raw?: () => string; // arbitrary signer output +}): HostAuthorizationSigner { + return { + hostPublicKey: getPublicKey(secretKey), + async signHttpAuthentication(request) { + if (options?.raw) return options.raw(); + const event = finalizeEvent({ + kind: options?.kind ?? 27235, + created_at: options?.now ?? NOW, + tags: [['u', options?.url ?? request.url], ['method', request.method], ['payload', options?.payload ?? request.payloadSha256Hex]], + content: '', + }, options?.key ?? secretKey); + return JSON.stringify(options?.corrupt ? { ...event, sig: `${event.sig.slice(0, -1)}${event.sig.endsWith('0') ? '1' : '0'}` } : event); + }, + }; +} + +/** Server-side NIP-98 verification — the relay boundary the module must + * satisfy: exact full URL, method, payload SHA-256 of the exact body, ±60s, + * and a valid signature by the declaring key. */ +function verifyNip98ServerSide(authorization: string | undefined, url: string, body: string): string { + assert.ok(typeof authorization === 'string' && authorization.startsWith('Nostr '), 'Nostr authorization header'); + const event = JSON.parse(Buffer.from(authorization.slice('Nostr '.length), 'base64').toString('utf8')) as Event; + assert.ok(verifyEvent(event), 'NIP-98 signature'); + assert.equal(event.kind, 27235); + assert.ok(Math.abs(event.created_at - NOW) <= 60, 'NIP-98 time window'); + const values = (name: string) => event.tags.filter(tag => tag[0] === name).map(tag => tag[1]); + assert.deepEqual(values('u'), [url], 'exact full URL binding'); + assert.deepEqual(values('method'), ['POST'], 'method binding'); + assert.deepEqual(values('payload'), [createHash('sha256').update(body).digest('hex')], 'payload hash of the exact body'); + return event.pubkey; +} + +type ScriptedRelay = { + readonly transport: RelayHttpTransport; + readonly requests: { url: string; method: string; headers: Record; body?: string }[]; + readonly members: Map; +}; + +/** Scripted in-memory relay (contract model above) with per-request capture. */ +function scriptedRelay(input: { + relayKey: Uint8Array; + enforced: boolean; + self?: string | null; + members?: readonly (readonly [string, string])[]; + roster?: readonly unknown[]; + communityId?: string; + host?: string; + claimOutcome?: (code: string, authorization: string | undefined) => { status: number; bodyText: string }; + queryOutcome?: (pubkey: string, authorization: string | undefined) => { status: number; bodyText: string }; +}): ScriptedRelay { + const host = input.host ?? 'relay.example.com'; + const self = input.self === undefined ? getPublicKey(input.relayKey) : input.self; + const members = new Map(input.members ?? []); + const requests: ScriptedRelay['requests'] = []; + const transport: RelayHttpTransport = async request => { + requests.push({ url: request.url, method: request.method, headers: { ...request.headers }, body: request.body }); + const url = new URL(request.url); + if (request.method === 'GET' && url.pathname === '/') { + const document: Record = { name: 'fixture', supported_nips: input.enforced ? [1, 43] : [1] }; + if (self) document.self = self; + return { status: 200, bodyText: JSON.stringify(document) }; + } + if (request.method === 'POST' && url.pathname === '/api/invites/claim') { + let code = ''; + try { code = String(JSON.parse(request.body ?? '{}').code); } catch { /* overrides still run */ } + if (input.claimOutcome) return input.claimOutcome(code, request.headers.authorization); + let pubkey: string; + try { pubkey = verifyNip98ServerSide(request.headers.authorization, request.url, request.body ?? ''); } + catch { return { status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }; } + const wasMember = members.has(pubkey); + members.set(pubkey, 'member'); + return { status: 200, bodyText: JSON.stringify({ status: wasMember ? 'already_member' : 'joined', community_id: input.communityId ?? COMMUNITY_ID, host, role: 'member' }) }; + } + if (request.method === 'POST' && url.pathname === '/query') { + let pubkey: string; + try { pubkey = verifyNip98ServerSide(request.headers.authorization, request.url, request.body ?? ''); } + catch { return { status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }; } + if (input.queryOutcome) return input.queryOutcome(pubkey, request.headers.authorization); + if (input.enforced && !members.has(pubkey)) return { status: 403, bodyText: JSON.stringify({ error: 'relay_membership_required' }) }; + return { status: 200, bodyText: JSON.stringify(input.roster ?? []) }; + } + return { status: 404, bodyText: JSON.stringify({ error: 'not_found' }) }; + }; + return { transport, requests, members }; +} + +test('positive wire flow: discovery, exact NIP-98 claim/query binding, verified roster readback, old roster accepted', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(); + const hostPubkey = getPublicKey(hostKey); + const relaySelf = getPublicKey(relayKey); + const fixture = scriptedRelay({ relayKey, enforced: true, roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 3600)] }); + const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', transport: fixture.transport, now: NOW }; + + const claim = await claimRelayMembershipInvite(input); + assert.deepEqual(claim, { status: 'joined', communityId: COMMUNITY_ID, host: 'relay.example.com', role: 'member', claimedAt: NOW }); + const secondClaim = await claimRelayMembershipInvite(input); + assert.equal(secondClaim.status, 'already_member'); // idempotent committed-row readback + const claimRequest = fixture.requests.at(-1)!; + assert.equal(claimRequest.method, 'POST'); + assert.equal(claimRequest.url, 'https://relay.example.com/api/invites/claim'); + assert.deepEqual(JSON.parse(claimRequest.body!), { code: 'v2.fixture-code' }); + // Server-side NIP-98 verification inside the fixture proves the exact URL, + // method and body-hash binding; a misbinding would have been a 401 above. + + const requirements = await discoverRelayMembershipRequirements({ relay: 'wss://relay.example.com', transport: fixture.transport }); + assert.deepEqual(requirements, { origin: 'https://relay.example.com/', relaySelf, membershipEnforced: true, supportedNips: [1, 43] }); + const discoveryRequest = fixture.requests.at(-1)!; + assert.equal(discoveryRequest.method, 'GET'); + assert.equal(discoveryRequest.url, 'https://relay.example.com/'); + assert.equal(discoveryRequest.headers.accept, 'application/nostr+json'); + + const evidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), claim, transport: fixture.transport, now: NOW }); + assert.equal(evidence.status, 'live-verified-member'); + assert.equal(evidence.liveAdmission, 'verified'); + assert.deepEqual(evidence.roster, { status: 'observed', role: 'member', createdAt: NOW - 3600, lagging: false }); + assert.deepEqual(evidence.claim, claim); + const queryRequest = fixture.requests.at(-1)!; + assert.equal(queryRequest.method, 'POST'); + assert.equal(queryRequest.url, 'https://relay.example.com/query'); + assert.deepEqual(JSON.parse(queryRequest.body!), [{ kinds: [13534], authors: [relaySelf] }]); + + // An old (30-day) valid roster is accepted: no created_at freshness gate. + const old = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 30 * 86400)] }); + const oldEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: old.transport, now: NOW }); + assert.equal(oldEvidence.status, 'live-verified-member'); + assert.deepEqual(oldEvidence.roster, { status: 'observed', role: 'member', createdAt: NOW - 30 * 86400, lagging: false }); + + // Lagging publication: the member row exists (gate passes) but the roster + // snapshot does not list the host yet — reported honestly, never as failure. + const lagging = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: [rosterEvent(relayKey, [], NOW)] }); + const laggingEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), claim, transport: lagging.transport, now: NOW }); + assert.equal(laggingEvidence.status, 'live-verified-member'); + assert.deepEqual(laggingEvidence.roster, { status: 'absent', lagging: true }); +}); + +test('misbound or forged signer output and forged rosters never verify or reach the wire', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(), otherKey = generateSecretKey(); + const fixture = scriptedRelay({ relayKey, enforced: true }); + const input = { relay: 'wss://relay.example.com', inviteCode: 'v2.fixture-code', transport: fixture.transport, now: NOW }; + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { key: otherKey }) }), /signer key does not match/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { url: 'https://evil.example.com/api/invites/claim' }) }), /not bound to this relay request URL/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { payload: '0'.repeat(64) }) }), /does not cover the request payload/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { now: NOW - 61 }) }), /acceptance window/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { kind: 1 }) }), /non-NIP-98/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { corrupt: true }) }), /signature is invalid/); + await assert.rejects(claimRelayMembershipInvite({ ...input, signer: hostSigner(hostKey, { raw: () => 'not json' }) }), /malformed NIP-98/); + assert.equal(fixture.requests.length, 0); // nothing left the module + + // A misbound signer cannot drive the gated query either: only the plain + // NIP-11 GET ever ran, no POST left the module. + await assert.rejects(verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey, { key: otherKey }), transport: fixture.transport, now: NOW }), /signer key does not match/); + assert.deepEqual(fixture.requests.map(request => request.method), ['GET']); + + // Forged rosters: a kind 13534 listing the host with an invented role is + // only evidence when signed by the relay self key. + const hostPubkey = getPublicKey(hostKey), attackerKey = generateSecretKey(); + const honest = rosterEvent(relayKey, [[hostPubkey, 'member']], NOW); + const forged = [ + rosterEvent(attackerKey, [[hostPubkey, 'owner']], NOW), // wrong signer + { ...honest, tags: [['-'], ['member', hostPubkey, 'owner']] }, // relay-key identity, forged content and signature + 'not an event', + ]; + const forgedFixture = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], roster: forged }); + const evidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: forgedFixture.transport, now: NOW }); + assert.equal(evidence.roster.status, 'unavailable'); + assert.equal(evidence.roster.role, undefined); + assert.equal(evidence.status, 'live-verified-member'); // via the gate, never via the forged list +}); + +test('enforced denial, open relay and unknown relay are never reported live-verified', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(); + const hostPubkey = getPublicKey(hostKey); + + const denied = scriptedRelay({ relayKey, enforced: true }); // host is not a member + const deniedEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: denied.transport, now: NOW }); + assert.equal(deniedEvidence.liveAdmission, 'denied'); + assert.equal(deniedEvidence.status, 'denied'); + + // 401 auth failure and a foreign 403 are not membership denial. + const unauthorized = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], queryOutcome: () => ({ status: 401, bodyText: JSON.stringify({ error: 'unauthorized' }) }) }); + assert.equal((await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: unauthorized.transport, now: NOW })).liveAdmission, 'unavailable'); + const foreign = scriptedRelay({ relayKey, enforced: true, members: [[hostPubkey, 'member']], queryOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: 'rate_limited' }) }) }); + assert.equal((await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: foreign.transport, now: NOW })).liveAdmission, 'unavailable'); + + // Open relay (self advertised, NIP-43 absent): the gate serves anyone, so a + // 200 read is roster evidence at most, never live verification. + const open = scriptedRelay({ relayKey, enforced: false, roster: [rosterEvent(relayKey, [[hostPubkey, 'member']], NOW)] }); + const openEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: open.transport, now: NOW }); + assert.equal(openEvidence.membershipEnforced, false); + assert.equal(openEvidence.liveAdmission, 'not-checked'); + assert.equal(openEvidence.status, 'roster-member'); + const openCommitted = scriptedRelay({ relayKey, enforced: false, roster: [] }); + const committedEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: openCommitted.transport, now: NOW }); + assert.equal(committedEvidence.liveAdmission, 'not-checked'); + assert.equal(committedEvidence.status, 'unknown'); // no claim supplied, nothing gated + + // No self key advertised: nothing to verify against, unknown. + const noSelf = scriptedRelay({ relayKey, enforced: false, self: null }); + const noSelfEvidence = await verifyDirectMembershipEvidence({ relay: 'wss://relay.example.com', signer: hostSigner(hostKey), transport: noSelf.transport, now: NOW }); + assert.equal(noSelfEvidence.discovery, 'observed'); + assert.equal(noSelfEvidence.relaySelf, null); + assert.deepEqual(noSelfEvidence.roster, { status: 'not-checked', lagging: false }); + assert.equal(noSelfEvidence.status, 'unknown'); + assert.deepEqual(noSelf.requests.map(request => request.url), ['https://relay.example.com/']); // no gated query was attempted +}); + +test('claim responses are validated and the invite code never appears in surfaced errors', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(); + const code = 'v2.super-secret-fixture-code'; + const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: code, now: NOW }; + + const invalid = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: 'invite_invalid' }) }) }); + await assert.rejects(claimRelayMembershipInvite({ ...input, transport: invalid.transport }), /invite_invalid/); + + // A hostile relay echoing the invite code back in its error field is coarsened. + const hostile = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 403, bodyText: JSON.stringify({ error: `code ${code} is wrong` }) }) }); + let hostileError: Error | undefined; + try { await claimRelayMembershipInvite({ ...input, transport: hostile.transport }); assert.fail('expected rejection'); } + catch (error) { hostileError = error as Error; } + assert.match(hostileError.message, /unspecified relay error/); + assert.ok(!hostileError.message.includes(code), 'invite code must not surface in errors'); + + for (const bodyText of [ + '{"status":"nope","community_id":"c","host":"relay.example.com","role":"member"}', // invalid status + '{"status":"joined","community_id":"c","role":"member"}', // missing host + '{"status":"joined","community_id":"c","host":"evil.example.com","role":"member"}', // different relay host + 'not json', + ]) { + const malformed = scriptedRelay({ relayKey, enforced: true, claimOutcome: () => ({ status: 200, bodyText }) }); + let error: Error | undefined; + try { await claimRelayMembershipInvite({ ...input, transport: malformed.transport }); assert.fail('expected rejection'); } + catch (caught) { error = caught as Error; } + assert.ok(!error.message.includes(code), 'invite code must not surface in errors'); + } +}); + +test('requests are bounded: timeout, external abort and redirects are refused truthfully', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(); + const input = { relay: 'wss://relay.example.com', signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW }; + const hang: RelayHttpTransport = async () => await new Promise(() => {}); // ignores the signal: the module must bound it itself + + let timeout: Error | undefined; + try { await claimRelayMembershipInvite({ ...input, transport: hang, timeoutMs: 40 }); assert.fail('expected rejection'); } + catch (caught) { timeout = caught as Error; } + assert.match(timeout.message, /commit status unknown \(Relay HTTP request timeout\)/); + assert.ok(!timeout.message.includes(input.inviteCode)); + + // An abort that already fired before the request leaves must reject promptly, + // not hang on a transport that ignores the signal. + const preAborted = new AbortController(); + preAborted.abort(); + let abortedEarly: Error | undefined; + try { await claimRelayMembershipInvite({ ...input, transport: hang, signal: preAborted.signal, timeoutMs: 5000 }); assert.fail('expected rejection'); } + catch (caught) { abortedEarly = caught as Error; } + assert.match(abortedEarly.message, /Relay HTTP request cancelled/); + assert.ok(!abortedEarly.message.includes(input.inviteCode)); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10).unref?.(); + await assert.rejects(claimRelayMembershipInvite({ ...input, transport: hang, signal: controller.signal, timeoutMs: 5000 }), /Relay HTTP request cancelled/); + + const redirect: RelayHttpTransport = async () => ({ status: 302, bodyText: '' }); + await assert.rejects(claimRelayMembershipInvite({ ...input, transport: redirect }), /redirect refused/); // authorization is never forwarded across a redirect + await assert.rejects(discoverRelayMembershipRequirements({ relay: 'wss://relay.example.com', transport: redirect }), /redirect refused/); +}); + +test('real loopback HTTP journey through fetchRelayTransport with server-side NIP-98 verification', async () => { + const relayKey = generateSecretKey(), hostKey = generateSecretKey(); + const hostPubkey = getPublicKey(hostKey); + const relaySelf = getPublicKey(relayKey); + const members = new Map(); + const roster = rosterEvent(relayKey, [[hostPubkey, 'member']], NOW - 60); + const verified: string[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + const hostHeader = request.headers.host ?? ''; + const send = (status: number, bodyText: string, contentType = 'application/json') => { response.writeHead(status, { 'content-type': contentType }); response.end(bodyText); }; + if (request.method === 'GET' && request.url === '/') { send(200, JSON.stringify({ name: 'loopback-fixture', supported_nips: [1, 43], self: relaySelf }), 'application/nostr+json'); return; } + if (request.method === 'GET' && request.url === '/redirect') { response.writeHead(302, { location: '/' }); response.end(); return; } + if (request.method === 'GET' && request.url === '/oversized') { response.writeHead(200, { 'content-length': '300000' }); response.end('x'.repeat(300000)); return; } + try { + const pubkey = verifyNip98ServerSide(request.headers.authorization, `http://${hostHeader}${request.url}`, body); + verified.push(pubkey); + if (request.url === '/api/invites/claim') { + const wasMember = members.has(pubkey); + members.set(pubkey, 'member'); + send(200, JSON.stringify({ status: wasMember ? 'already_member' : 'joined', community_id: COMMUNITY_ID, host: hostHeader, role: 'member' })); + return; + } + if (request.url === '/query') { + if (!members.has(pubkey)) { send(403, JSON.stringify({ error: 'relay_membership_required' })); return; } + send(200, JSON.stringify([roster])); + return; + } + send(404, JSON.stringify({ error: 'not_found' })); + } catch (error) { + send(401, JSON.stringify({ error: `unauthorized: ${(error as Error).message}` })); + } + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const relay = `http://127.0.0.1:${address.port}`; + try { + const claim = await claimRelayMembershipInvite({ relay, signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW }); + assert.equal(claim.status, 'joined'); + assert.equal(claim.host, `127.0.0.1:${address.port}`); + assert.equal((await claimRelayMembershipInvite({ relay, signer: hostSigner(hostKey), inviteCode: 'v2.fixture-code', now: NOW })).status, 'already_member'); + const evidence = await verifyDirectMembershipEvidence({ relay, signer: hostSigner(hostKey), claim, now: NOW }); // default fetchRelayTransport + assert.equal(evidence.status, 'live-verified-member'); + assert.equal(evidence.liveAdmission, 'verified'); + assert.deepEqual(evidence.roster, { status: 'observed', role: 'member', createdAt: roster.created_at, lagging: false }); + assert.deepEqual(verified, [hostPubkey, hostPubkey, hostPubkey]); // claim + both queries verified server-side + const transport = fetchRelayTransport(); + await assert.rejects(transport({ url: `${relay}/redirect`, method: 'GET', headers: {}, body: undefined, timeoutMs: 2000, signal: new AbortController().signal })); + await assert.rejects(transport({ url: `${relay}/oversized`, method: 'GET', headers: {}, body: undefined, timeoutMs: 2000, signal: new AbortController().signal }), /bounded size/); + } finally { + server.closeAllConnections(); + server.close(); + await once(server, 'close'); + } +});