nsecbunkerd/tests/nip46-backend.test.ts
Padreug ea923b472d
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
feat(transport): port the NIP-46 backend off NDK onto the relay pool (#42)
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
2026-06-27 00:29:02 +02:00

138 lines
5.4 KiB
TypeScript

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<string, (r: { result: string; error?: string }) => 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<void> {
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();
});