Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
138 lines
5.4 KiB
TypeScript
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();
|
|
});
|