From ea923b472deba390e3a6e983c23063a66748cd76 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 27 Jun 2026 00:29:02 +0200 Subject: [PATCH] feat(transport): port the NIP-46 backend off NDK onto the relay pool (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second increment of the NDK -> nostr-tools transport swap. The daemon's backend signing path no longer uses NDK at all. - nip46/transport.ts: the NIP-46 RPC wire layer over the RelayPool, replacing NDKNostrRpc. Same crypto + framing so existing clients (lnbits, the spire) are unaffected byte-for-byte: adaptive nip04/nip44 envelope (nip04 iff content has `?iv=`, fallback to the other), verify the kind:24133 signature, JSON `{id,method,params}` in / `{id,result,error}` out, signed as the held key and `#p`-tagged to the client. - backend/index.ts: the Backend is rebuilt on that transport instead of `extends NDKNip46Backend`. The dispatch + response strings match NDK's strategies exactly (connect->ack, ping->pong, get_public_key, sign_event-> signed event JSON, nip04/44 encrypt/decrypt, reject->error/"Not authorized"). The ACL hook (pubkeyAllowed -> permitCallback) is unchanged; the ACL only reads `.kind` off the sign_event payload, so a plain parsed event suffices. - backend/token-store.ts: the prisma-backed connection-token redemption (validateToken/applyToken) split out of the Backend and injected, so the protocol layer has no database dependency and is unit-testable. Logic unchanged (#24/#25 live-lifecycle semantics preserved). - run.ts: the daemon now drives a RelayPool (heartbeat on) for the backend transport instead of an NDK instance + attachIndefiniteReconnect; startKey wires the prisma applyToken. - relay-pool.ts: publish() now retries across a reconnect window — a publish that lands mid-flap rejects ("relay connection errored"), so we wait for the pool to recover and retry (relays dedupe by id; clients match by request id). Test (tests/nip46-backend.test.ts): a real nostr-tools NIP-46 client drives connect/ping/get_public_key/sign_event/nip44_encrypt through a mock relay, asserts each response, FLAPS the relay, and asserts the backend still answers — then checks the deny path returns "Not authorized". Green. lifecycle + relay suites unchanged. The admin interface (NDKRpc) + the getKeys listing still use NDK; that's the next increment. NDK remains a dependency until then. Refs: #42, #41, #25, #24, #21, #9 --- package.json | 5 +- src/daemon/backend/index.ts | 273 ++++++++++++++++-------------- src/daemon/backend/token-store.ts | 57 +++++++ src/daemon/lib/relay-pool.ts | 26 ++- src/daemon/nip46/transport.ts | 162 ++++++++++++++++++ src/daemon/nip46/types.ts | 50 ++++++ src/daemon/run.ts | 41 ++--- tests/nip46-backend.test.ts | 138 +++++++++++++++ 8 files changed, 597 insertions(+), 155 deletions(-) create mode 100644 src/daemon/backend/token-store.ts create mode 100644 src/daemon/nip46/transport.ts create mode 100644 src/daemon/nip46/types.ts create mode 100644 tests/nip46-backend.test.ts diff --git a/package.json b/package.json index 12a64b6..81bda4e 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,10 @@ "build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client", "build:client": "tsup src/client.ts -d dist/client", "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts", - "test:relay": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/relay-pool.test.ts", + "test:relay": "TS_NODE_TRANSPILE_ONLY=1 node --test-force-exit -r ts-node/register --test tests/relay-pool.test.ts", + "test:nip46": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/nip46-backend.test.ts", "test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts", - "test:all": "npm run test && npm run test:relay && npm run test:integration", + "test:all": "npm run test && npm run test:relay && npm run test:nip46 && npm run test:integration", "prisma:generate": "npx prisma generate", "prisma:migrate": "npx prisma migrate deploy", "prisma:create": "npx prisma db push --preview-feature", diff --git a/src/daemon/backend/index.ts b/src/daemon/backend/index.ts index 91f2f58..1562fb0 100644 --- a/src/daemon/backend/index.ts +++ b/src/daemon/backend/index.ts @@ -1,130 +1,147 @@ -import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk'; -import prisma from '../../db.js'; -import type {FastifyInstance} from "fastify"; -import { grantIsLive } from '../lib/acl/index.js'; - -export class Backend extends NDKNip46Backend { - public baseUrl?: string; - public fastify: FastifyInstance; - - constructor( - ndk: NDK, - fastify: FastifyInstance, - key: string, - cb: Nip46PermitCallback, - baseUrl?: string - ) { - const signer = new NDKPrivateKeySigner(key); - super(ndk, signer, cb); - - this.baseUrl = baseUrl; - this.fastify = fastify; - } - - /** - * Override NDKNip46Backend.start() to await the kind-24133 - * subscription's EOSE before resolving. The base implementation - * calls `this.ndk.subscribe(...)` and returns immediately — the - * NDKSubscription queues a REQ on the relay connection but the - * relay's acknowledgement (EOSE) hasn't arrived yet. Any caller - * that publishes a NIP-46 event in the immediate window after - * `start()` returns races against the relay registering this - * subscription. - * - * aiolabs/lnbits#33's eager-bind chain publishes a NIP-46 - * `connect` event in the same HTTP round-trip as `create_new_key`, - * which loses this race deterministically — the bunker never - * sees the connect event because its subscription wasn't yet - * registered with the relay when the event was broadcast. - * - * Awaiting EOSE closes the race: by the time `start()` resolves, - * the relay has confirmed it has the bunker's subscription on - * file and will route matching kind-24133 events to it. - * - * See aiolabs/nsecbunkerd#9 for the full diagnosis. - */ - async start(): Promise { - this.localUser = await this.signer.user(); - await new Promise((resolve) => { - // Pin this subscription to the daemon's explicit relays via - // `relayUrls`. Without that, NDK 3.x's outbox routing tries to - // resolve the relay set from `this.localUser.pubkey`'s NIP-65 - // relay list (kind:10002). Newly-provisioned bunker keys have - // no published kind:10002 yet, so NDK's subscription manager - // queues the REQ waiting for a relay list that will never - // arrive — the kind:24133 subscription never lands on the - // wire, and inbound NIP-46 events (sign_event, get_public_key, - // nip44_*) targeted at this key get dropped by the relay - // with "Filter didn't match" because the bunker isn't actually - // subscribed for them. - // - // `relayUrls` was added in NDK 2.13.0 as the supported way to - // bypass outbox routing per subscription (see - // NDKSubscriptionOptions.relayUrls in @nostr-dev-kit/ndk). - // The relay set built from these URLs matches what the rest - // of the bunker uses (admin RPC channel + per-key Backend - // channels alike), so events flow through the same connection - // the admin interface already established. - // - // See aiolabs/nsecbunkerd#21. - const sub = this.ndk.subscribe( - { - kinds: [24133], - "#p": [this.localUser!.pubkey], - }, - { - closeOnEose: false, - relayUrls: this.ndk.explicitRelayUrls, - } - ); - sub.on("event", (e: any) => this.handleIncomingEvent(e)); - sub.on("eose", () => resolve()); - }); - } - - private async validateToken(token: string) { - if (!token) throw new Error("Invalid token"); - - const tokenRecord = await prisma.token.findUnique({ where: { - token - }, include: { policy: { include: { rules: true } } } }); - - if (!tokenRecord) throw new Error("Token not found"); - if (tokenRecord.redeemedAt) throw new Error("Token already redeemed"); - if (!tokenRecord.policy) throw new Error("Policy not found"); - // Revoke + expiry via the single grantIsLive predicate — the exact - // check the sign-time ACL uses, so redeem-time and sign-time cannot - // drift (the root of #24). See aiolabs/nsecbunkerd#25. - if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked"); - - return tokenRecord; - } - - async applyToken(userPubkey: string, token: string): Promise { - const tokenRecord = await this.validateToken(token); - const keyName = tokenRecord.keyName; - - // Record ONLY the binding (KeyUser <- Token). Under #25 the token's - // policy is evaluated live at sign time (checkIfPubkeyAllowed step 4) - // off Token -> Policy -> PolicyRule, NOT photocopied into - // SigningCondition rows here. That photocopy was the root of #24: the - // copy carried no expiry/revoke and short-circuited the live check, so - // an expired or revoked token kept signing forever. With no copy, the - // token's lifecycle is re-checked on every request and there is nothing - // to keep in sync. - const upsertedUser = await prisma.keyUser.upsert({ - where: { unique_key_user: { keyName, userPubkey } }, - update: { }, - create: { keyName, userPubkey, description: tokenRecord.clientName }, - }); - - await prisma.token.update({ - where: { id: tokenRecord.id }, - data: { - redeemedAt: new Date(), - keyUserId: upsertedUser.id, - } - }); - } +import type { FastifyInstance } from "fastify"; +import type { RelayPool } from "../lib/relay-pool.js"; +import { Nip46Transport, secretKeyBytes } from "../nip46/transport.js"; +import type { Nip46PermitCallback, Nip46Request } from "../nip46/types.js"; +export interface BackendConfig { + pool: RelayPool; + /** The held key (nsec1… or hex). Never leaves this object. */ + nsec: string; + permitCallback: Nip46PermitCallback; + /** Connection-token redemption hook. The daemon injects the prisma-backed + * `applyToken` from `./token-store`; tests inject a stub. Required only if + * clients connect with a token. */ + applyToken?: (remotePubkey: string, token: string) => Promise; + baseUrl?: string; + fastify?: FastifyInstance; +} + +/** + * NIP-46 signing backend for one held key (aiolabs/nsecbunkerd#42). + * + * Was `extends NDKNip46Backend`; now built on {@link Nip46Transport} over the + * RelayPool so the signing path survives relay flaps (#41) — NDK never replayed + * the kind:24133 subscription on reconnect. The protocol, response strings, and + * ACL hook (`pubkeyAllowed` → permitCallback) are preserved byte-for-byte so + * existing clients (lnbits, the spire) are unaffected. The token-redemption + * logic (`validateToken`/`applyToken`) is unchanged from the NDK version. + */ +export class Backend { + public baseUrl?: string; + public fastify?: FastifyInstance; + public readonly transport: Nip46Transport; + + private readonly permitCallback: Nip46PermitCallback; + private readonly applyTokenFn: (remotePubkey: string, token: string) => Promise; + + constructor(config: BackendConfig) { + this.transport = new Nip46Transport(secretKeyBytes(config.nsec), config.pool); + this.permitCallback = config.permitCallback; + this.applyTokenFn = + config.applyToken ?? + (async () => { + throw new Error("connection token redemption not configured"); + }); + this.baseUrl = config.baseUrl; + this.fastify = config.fastify; + } + + /** The held key's public key (hex) — the npub the bunker signs as. */ + get pubkey(): string { + return this.transport.pubkey; + } + + /** Subscribe to this key's kind:24133 channel and serve requests. Resolves + * after the subscription's first EOSE (the #9 start-race guard). */ + async start(): Promise { + await this.transport.start((req) => void this.handleRequest(req)); + } + + private async pubkeyAllowed(params: { + id: string; + pubkey: string; + method: any; + params?: any; + }): Promise { + return this.permitCallback(params); + } + + private async handleRequest(req: Nip46Request): Promise { + const { id, method, params, remotePubkey, encryption } = req; + try { + const result = await this.dispatch(id, method, params, remotePubkey); + if (result !== undefined) { + await this.transport.sendResponse(id, remotePubkey, result, encryption); + } else { + await this.transport.sendResponse(id, remotePubkey, "error", encryption, "Not authorized"); + } + } catch (e: any) { + try { + await this.transport.sendResponse(id, remotePubkey, "error", encryption, e?.message ?? String(e)); + } catch { + /* publish failed; nothing more we can do */ + } + } + } + + /** Route a request to its handler. Returns the result string, or undefined + * for "Not authorized" — matching NDK's strategy contract exactly. */ + private async dispatch( + id: string, + method: string, + params: string[], + remotePubkey: string, + ): Promise { + switch (method) { + case "connect": { + const [, token] = params; + if (token) await this.applyTokenFn(remotePubkey, token); + const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "connect", params: token }); + return ok ? "ack" : undefined; + } + case "ping": { + const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "ping" }); + return ok ? "pong" : undefined; + } + case "get_public_key": + return this.pubkey; + case "sign_event": { + const [eventString] = params; + const tmpl = JSON.parse(eventString); + const ok = await this.pubkeyAllowed({ + id, + pubkey: remotePubkey, + method: "sign_event", + params: tmpl, // ACL reads only `.kind` + }); + if (!ok) return undefined; + const signed = this.transport.sign({ + kind: tmpl.kind, + created_at: tmpl.created_at ?? Math.floor(Date.now() / 1000), + tags: tmpl.tags ?? [], + content: tmpl.content ?? "", + }); + return JSON.stringify(signed); + } + case "nip44_encrypt": + case "nip04_encrypt": { + const [recipientPubkey, payload] = params; + const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: payload }); + if (!ok) return undefined; + const scheme = method === "nip04_encrypt" ? "nip04" : "nip44"; + return this.transport.encryptTo(recipientPubkey, payload, scheme); + } + case "nip44_decrypt": + case "nip04_decrypt": { + const [senderPubkey, ciphertext] = params; + const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: ciphertext }); + if (!ok) return undefined; + const scheme = method === "nip04_decrypt" ? "nip04" : "nip44"; + return this.transport.decryptFrom(senderPubkey, ciphertext, scheme); + } + default: + // Unknown method — undefined surfaces as "Not authorized". + return undefined; + } + } } diff --git a/src/daemon/backend/token-store.ts b/src/daemon/backend/token-store.ts new file mode 100644 index 0000000..810be6c --- /dev/null +++ b/src/daemon/backend/token-store.ts @@ -0,0 +1,57 @@ +import prisma from "../../db.js"; +import { grantIsLive } from "../lib/acl/index.js"; + +/** + * Prisma-backed connection-token redemption (aiolabs/nsecbunkerd#42). + * + * Split out of the Backend so the NIP-46 protocol layer (`backend/index.ts`) + * has no database dependency and can be unit-tested without a generated prisma + * client. The daemon wires {@link applyToken} into the Backend as its + * `applyToken` hook; tests inject a stub. Logic is unchanged from the prior + * NDK-based Backend's `validateToken`/`applyToken`. + */ + +async function validateToken(token: string) { + if (!token) throw new Error("Invalid token"); + + const tokenRecord = await prisma.token.findUnique({ + where: { token }, + include: { policy: { include: { rules: true } } }, + }); + + if (!tokenRecord) throw new Error("Token not found"); + if (tokenRecord.redeemedAt) throw new Error("Token already redeemed"); + if (!tokenRecord.policy) throw new Error("Policy not found"); + // Revoke + expiry via the single grantIsLive predicate — the exact check + // the sign-time ACL uses, so redeem-time and sign-time cannot drift (the + // root of #24). See aiolabs/nsecbunkerd#25. + if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked"); + + return tokenRecord; +} + +export async function applyToken(userPubkey: string, token: string): Promise { + const tokenRecord = await validateToken(token); + const keyName = tokenRecord.keyName; + + // Record ONLY the binding (KeyUser <- Token). Under #25 the token's policy + // is evaluated live at sign time (checkIfPubkeyAllowed step 4) off + // Token -> Policy -> PolicyRule, NOT photocopied into SigningCondition rows + // here. That photocopy was the root of #24: the copy carried no + // expiry/revoke and short-circuited the live check, so an expired or revoked + // token kept signing forever. With no copy, the token's lifecycle is + // re-checked on every request and there is nothing to keep in sync. + const upsertedUser = await prisma.keyUser.upsert({ + where: { unique_key_user: { keyName, userPubkey } }, + update: {}, + create: { keyName, userPubkey, description: tokenRecord.clientName }, + }); + + await prisma.token.update({ + where: { id: tokenRecord.id }, + data: { + redeemedAt: new Date(), + keyUserId: upsertedUser.id, + }, + }); +} diff --git a/src/daemon/lib/relay-pool.ts b/src/daemon/lib/relay-pool.ts index c3b476f..6ca0c89 100644 --- a/src/daemon/lib/relay-pool.ts +++ b/src/daemon/lib/relay-pool.ts @@ -343,16 +343,30 @@ export class RelayPool { }); } - /** Publish to every relay; resolves if at least one accepts it. */ - async publish(event: Event): Promise { - const results = await Promise.allSettled(this.relays.map((r) => r.publish(event))); - if (!results.some((r) => r.status === "fulfilled")) { - const reasons = results + /** + * Publish to every relay; resolves once at least one accepts it. Retries + * across a reconnect window: a publish that lands while a relay is mid-flap + * rejects ("relay connection errored"/"relay not connected"), so we wait for + * the pool to recover and try again. Safe to retry — relays dedupe by event + * id and a NIP-46 client matches the response by request id. Bounded so a + * genuinely-down relay set still surfaces an error rather than hanging. + */ + async publish(event: Event, opts: { retries?: number; retryDelayMs?: number } = {}): Promise { + const retries = opts.retries ?? 4; + const retryDelayMs = opts.retryDelayMs ?? 300; + let lastReasons = ""; + for (let attempt = 0; attempt <= retries; attempt++) { + const results = await Promise.allSettled(this.relays.map((r) => r.publish(event))); + if (results.some((r) => r.status === "fulfilled")) return; + lastReasons = results .map((r) => (r.status === "rejected" ? r.reason?.message ?? r.reason : "")) .filter(Boolean) .join("; "); - throw new Error(`publish failed on all relays: ${reasons}`); + if (attempt < retries) { + await new Promise((r) => setTimeout(r, retryDelayMs)); + } } + throw new Error(`publish failed on all relays after ${retries + 1} attempts: ${lastReasons}`); } /** Relays currently holding an open socket. */ diff --git a/src/daemon/nip46/transport.ts b/src/daemon/nip46/transport.ts new file mode 100644 index 0000000..0b6dd6e --- /dev/null +++ b/src/daemon/nip46/transport.ts @@ -0,0 +1,162 @@ +import { finalizeEvent, verifyEvent, getPublicKey, nip04, nip44, nip19 } from "nostr-tools"; +import type { Event } from "nostr-tools"; +import type { RelayPool } from "../lib/relay-pool.js"; +import type { Nip46Request } from "./types.js"; + +const NIP46_KIND = 24133; + +/** nsec1… or 64-hex → 32-byte secret key. */ +export function secretKeyBytes(key: string): Uint8Array { + if (key.startsWith("nsec1")) { + const { type, data } = nip19.decode(key); + if (type !== "nsec") throw new Error("not an nsec"); + return data as Uint8Array; + } + const bytes = new Uint8Array(key.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(key.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Nip46Transport — the NIP-46 RPC wire layer over a {@link RelayPool}, + * replacing NDK's `NDKNostrRpc` (aiolabs/nsecbunkerd#42). It owns exactly the + * crypto + framing NDK did, so existing clients (lnbits, the spire) keep + * working byte-for-byte: + * + * - inbound: verify the kind:24133 signature, decrypt the content (nip04 if it + * carries `?iv=`, else nip44 — falling back to the other on failure, the same + * adaptive scheme as `NDKNostrRpc.parseEvent`), JSON-parse `{id, method, + * params}`. + * - outbound: JSON `{id, result, error?}`, encrypted to the client with the + * SAME scheme the request used, signed as the held key, published kind:24133 + * `#p`-tagged to the client. + * + * The held key never leaves this object; it signs/encrypts only. + */ +export class Nip46Transport { + public readonly pubkey: string; + private sub: { close: () => void } | null = null; + + constructor( + private readonly sk: Uint8Array, + private readonly pool: RelayPool, + private readonly log: (...args: any[]) => void = () => {}, + ) { + this.pubkey = getPublicKey(sk); + } + + /** Start listening for this key's kind:24133 requests. Resolves once the + * subscription's first EOSE lands (the #9 start-race guard). */ + async start(onRequest: (req: Nip46Request) => void): Promise { + this.sub = await this.pool.subscribeAwaitingEose( + [{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], + (event: Event) => { + const req = this.parse(event); + if (req) onRequest(req); + }, + { id: `nip46:${this.pubkey}` }, + ); + } + + stop(): void { + this.sub?.close(); + this.sub = null; + } + + /** Decrypt + verify an inbound event into a request, or null if it's not a + * valid request we can read. */ + private parse(event: Event): Nip46Request | null { + if (!verifyEvent(event)) { + this.log("dropping event with invalid signature", event.id); + return null; + } + const remotePubkey = event.pubkey; + // nip04 ciphertext carries a `?iv=`; nip44 does not. + let encryption: "nip04" | "nip44" = event.content.includes("?iv=") ? "nip04" : "nip44"; + let decrypted: string; + try { + decrypted = this.decrypt(remotePubkey, event.content, encryption); + } catch { + encryption = encryption === "nip04" ? "nip44" : "nip04"; + try { + decrypted = this.decrypt(remotePubkey, event.content, encryption); + } catch (e) { + this.log("failed to decrypt request", e); + return null; + } + } + + let parsed: any; + try { + parsed = JSON.parse(decrypted); + } catch { + this.log("request content was not JSON"); + return null; + } + if (!parsed?.method) return null; // it's a response, not a request + return { + id: parsed.id, + method: parsed.method, + params: parsed.params ?? [], + remotePubkey, + encryption, + }; + } + + /** Encrypt + sign + publish a NIP-46 response, matching the request's scheme. */ + async sendResponse( + id: string, + remotePubkey: string, + result: string, + encryption: "nip04" | "nip44", + error?: string, + ): Promise { + const payload: { id: string; result: string; error?: string } = { id, result }; + if (error) payload.error = error; + const content = this.encrypt(remotePubkey, JSON.stringify(payload), encryption); + const event = finalizeEvent( + { + kind: NIP46_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", remotePubkey]], + content, + }, + this.sk, + ); + await this.pool.publish(event); + } + + private encrypt(peerPubkey: string, plaintext: string, scheme: "nip04" | "nip44"): string { + if (scheme === "nip04") { + return nip04.encrypt(this.sk, peerPubkey, plaintext); + } + const convKey = nip44.getConversationKey(this.sk, peerPubkey); + return nip44.encrypt(plaintext, convKey); + } + + private decrypt(peerPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string { + if (scheme === "nip04") { + return nip04.decrypt(this.sk, peerPubkey, ciphertext); + } + const convKey = nip44.getConversationKey(this.sk, peerPubkey); + return nip44.decrypt(ciphertext, convKey); + } + + /** Encrypt an arbitrary payload to a recipient (the nip04/44_encrypt method + * signs/encrypts on the client's behalf, as the held key). */ + encryptTo(recipientPubkey: string, payload: string, scheme: "nip04" | "nip44"): string { + return this.encrypt(recipientPubkey, payload, scheme); + } + + /** Decrypt a payload from a counterparty (the nip04/44_decrypt method). */ + decryptFrom(senderPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string { + return this.decrypt(senderPubkey, ciphertext, scheme); + } + + /** Sign an event template as the held key (the sign_event method). */ + sign(template: { kind: number; created_at: number; tags: string[][]; content: string }): Event { + return finalizeEvent(template, this.sk); + } +} diff --git a/src/daemon/nip46/types.ts b/src/daemon/nip46/types.ts new file mode 100644 index 0000000..1609098 --- /dev/null +++ b/src/daemon/nip46/types.ts @@ -0,0 +1,50 @@ +/** + * Local NIP-46 types (aiolabs/nsecbunkerd#42). + * + * These replace the identically-named types we used to import from + * `@nostr-dev-kit/ndk`, so the daemon's signing path no longer depends on NDK. + * Kept structurally identical to NDK's so the ACL callback + * (`signingAuthorizationCallback`) and the admin layer don't have to change. + */ + +export type NIP46Method = + | "connect" + | "sign_event" + | "nip04_encrypt" + | "nip04_decrypt" + | "nip44_encrypt" + | "nip44_decrypt" + | "get_public_key" + | "ping"; + +export interface Nip46PermitCallbackParams { + /** Request id. */ + id: string; + /** The connected client's pubkey (hex). */ + pubkey: string; + /** The NIP-46 method being requested. */ + method: NIP46Method; + /** + * The method's payload. For `sign_event` it's the parsed event object (the + * ACL only reads `.kind`); for the encrypt/decrypt methods it's the payload + * string; for `connect` it's the token; otherwise undefined. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: any; +} + +export type Nip46PermitCallback = (params: Nip46PermitCallbackParams) => Promise; + +/** Hook to redeem a connection token (the bunker's `applyToken`). */ +export type Nip46ApplyTokenCallback = (pubkey: string, token: string) => Promise; + +/** A decrypted, verified inbound NIP-46 request. */ +export interface Nip46Request { + id: string; + method: string; + params: string[]; + /** The verified sender (client) pubkey, hex. */ + remotePubkey: string; + /** Which envelope encryption the client used; the response must match it. */ + encryption: "nip04" | "nip44"; +} diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 738dc77..f4ec4a7 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -1,6 +1,9 @@ -import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk'; +import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; import { nip19, utils as nostrUtils } from 'nostr-tools'; import { Backend } from './backend/index.js'; +import { applyToken } from './backend/token-store.js'; +import { RelayPool } from './lib/relay-pool.js'; +import type { Nip46PermitCallback, Nip46PermitCallbackParams } from './nip46/types.js'; import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js'; import AdminInterface from './admin/index.js'; import { IConfig } from '../config/index.js'; @@ -15,7 +18,6 @@ import FastifyView from '@fastify/view'; import Handlebars from "handlebars"; import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js"; import {processRegistrationWebHandler} from "./web/authorize.js"; -import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js"; export type Key = { name: string; @@ -151,7 +153,7 @@ class Daemon { private config: DaemonConfig; private activeKeys: Record; private adminInterface: AdminInterface; - private ndk: NDK; + private pool: RelayPool; public fastify: FastifyInstance; constructor(config: DaemonConfig) { @@ -167,21 +169,15 @@ class Daemon { this.fastify = Fastify({ logger: true }); this.fastify.register(FastifyFormBody); - this.ndk = new NDK({ - explicitRelayUrls: config.nostr.relays, + // Backend transport. The RelayPool owns its reconnect loop and + // re-subscribes every held key's kind:24133 channel on every reconnect, + // so the bunker can't go deaf after a relay flap (#41/#42) — the failure + // the old NDK transport + attachIndefiniteReconnect (#20) couldn't close. + // The heartbeat adds sleep/wake (time-jump) recovery. + this.pool = new RelayPool(config.nostr.relays, { + log: (...a: any[]) => console.log(...a), + heartbeatMs: 30_000, }); - this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) ); - this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); }); - - this.ndk.pool.on('relay:disconnect', (r) => { - console.log(`🚫 Disconnected from ${r.url}`); - }); - - // Override NDK's "give up after detecting flapping" behavior so the - // bunker's backend NDK keeps trying to reconnect indefinitely. - // Without this, an ECONNREFUSED storm at boot (relay not yet up) - // permanently strands the bunker. See aiolabs/nsecbunkerd#20. - attachIndefiniteReconnect(this.ndk, 'backend'); } async startWebAuth() { @@ -345,7 +341,7 @@ class Daemon { } async start() { - await this.ndk.connect(5000); + this.pool.start(); await this.startWebAuth(); await this.startKeys(); @@ -365,7 +361,14 @@ class Daemon { // passing it here"). The bech32-decode workaround for #8 was // tied to NDK 2.8.1's old constructor behavior and is no // longer needed post-#14 NDK bump. - const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl); + const backend = new Backend({ + pool: this.pool, + fastify: this.fastify, + nsec, + permitCallback: cb, + applyToken, + baseUrl: this.config.baseUrl, + }); await backend.start(); } diff --git a/tests/nip46-backend.test.ts b/tests/nip46-backend.test.ts new file mode 100644 index 0000000..5116567 --- /dev/null +++ b/tests/nip46-backend.test.ts @@ -0,0 +1,138 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + finalizeEvent, + verifyEvent, + generateSecretKey, + getPublicKey, + nip44, + type Event, +} from "nostr-tools"; +import { MockRelay } from "./helpers/mock-relay"; +import { RelayPool } from "../src/daemon/lib/relay-pool"; +import { Backend } from "../src/daemon/backend/index"; + +/** + * End-to-end NIP-46 round-trip against the ported Backend (#42): a real + * nostr-tools client sends connect / ping / get_public_key / sign_event / + * nip44_encrypt through a mock relay; we assert the responses, then FLAP the + * relay and assert the backend still answers — proving the whole signing + * protocol survives a reconnect, which it never did on the NDK transport (#41). + */ + +const NIP46_KIND = 24133; + +/** Minimal NIP-46 client: publishes requests to the bunker pubkey, decrypts the + * responses addressed back to it. nip44 envelope (the modern default). */ +class TestClient { + readonly sk = generateSecretKey(); + readonly pubkey = getPublicKey(this.sk); + private pending = new Map void>(); + private n = 0; + + constructor( + private readonly pool: RelayPool, + private readonly bunkerPubkey: string, + ) { + this.pool.subscribe([{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], (e: Event) => { + const ck = nip44.getConversationKey(this.sk, e.pubkey); + const msg = JSON.parse(nip44.decrypt(e.content, ck)); + this.pending.get(msg.id)?.({ result: msg.result, error: msg.error }); + }); + } + + request(method: string, params: string[] = [], timeoutMs = 5000): Promise<{ result: string; error?: string }> { + const id = `req-${++this.n}`; + const ck = nip44.getConversationKey(this.sk, this.bunkerPubkey); + const content = nip44.encrypt(JSON.stringify({ id, method, params }), ck); + const event = finalizeEvent( + { kind: NIP46_KIND, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.bunkerPubkey]], content }, + this.sk, + ); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs); + this.pending.set(id, (r) => { + clearTimeout(timer); + resolve(r); + }); + void this.pool.publish(event); + }); + } +} + +async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise { + const start = Date.now(); + while (!pred()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, stepMs)); + } +} + +test("Backend serves NIP-46 over the pool and survives a relay flap (#42)", async () => { + const relay = new MockRelay(); + await relay.start(); + + // The held key the bunker signs as. + const bunkerSk = generateSecretKey(); + const bunkerPubkey = getPublicKey(bunkerSk); + const bunkerNsec = Buffer.from(bunkerSk).toString("hex"); + + let allow = true; + const seen: string[] = []; + const bunkerPool = new RelayPool([relay.url], { log: () => {} }); + bunkerPool.start(); + const backend = new Backend({ + pool: bunkerPool, + nsec: bunkerNsec, + permitCallback: async (p) => { + seen.push(p.method); + return allow; + }, + applyToken: async () => { + /* no DB in this test */ + }, + }); + await backend.start(); + + const clientPool = new RelayPool([relay.url], { log: () => {} }); + clientPool.start(); + await waitFor(() => clientPool.connectedCount() === 1 && bunkerPool.healthy()); + const client = new TestClient(clientPool, bunkerPubkey); + + // connect -> ack + assert.equal((await client.request("connect", ["", ""])).result, "ack"); + // ping -> pong + assert.equal((await client.request("ping")).result, "pong"); + // get_public_key -> the bunker pubkey + assert.equal((await client.request("get_public_key")).result, bunkerPubkey); + + // sign_event -> a valid event signed AS the bunker key + const tmpl = { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: "hi from atm" }; + const signRes = await client.request("sign_event", [JSON.stringify(tmpl)]); + const signed = JSON.parse(signRes.result) as Event; + assert.equal(signed.pubkey, bunkerPubkey, "signed as the held key"); + assert.equal(signed.kind, 1); + assert.ok(verifyEvent(signed), "signature valid"); + + // nip44_encrypt(clientPubkey, payload) -> ciphertext from bunker to client + const enc = await client.request("nip44_encrypt", [client.pubkey, "secret-payload"]); + const ck = nip44.getConversationKey(client.sk, bunkerPubkey); + assert.equal(nip44.decrypt(enc.result, ck), "secret-payload", "nip44_encrypt round-trips"); + + // FLAP the relay, then assert the backend still answers. + await relay.flap(); + await waitFor(() => bunkerPool.healthy() && clientPool.healthy()); + assert.equal((await client.request("ping")).result, "pong", "still serving after flap"); + + // Rejection path: permit returns false -> "Not authorized". + allow = false; + const denied = await client.request("ping"); + assert.equal(denied.result, "error"); + assert.equal(denied.error, "Not authorized"); + + assert.ok(seen.includes("sign_event") && seen.includes("connect"), "permit callback was consulted"); + + bunkerPool.stop(); + clientPool.stop(); + await relay.stop(); +});