feat(transport): port the NIP-46 backend off NDK onto the relay pool (#42)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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
This commit is contained in:
parent
1409941d11
commit
ea923b472d
8 changed files with 584 additions and 142 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
import type { RelayPool } from "../lib/relay-pool.js";
|
||||
import { Nip46Transport, secretKeyBytes } from "../nip46/transport.js";
|
||||
import type { Nip46PermitCallback, Nip46Request } from "../nip46/types.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;
|
||||
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<void>;
|
||||
baseUrl?: string;
|
||||
fastify?: FastifyInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* NIP-46 signing backend for one held key (aiolabs/nsecbunkerd#42).
|
||||
*
|
||||
* 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.
|
||||
* 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<void>;
|
||||
|
||||
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<void> {
|
||||
this.localUser = await this.signer.user();
|
||||
await new Promise<void>((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,
|
||||
await this.transport.start((req) => void this.handleRequest(req));
|
||||
}
|
||||
);
|
||||
sub.on("event", (e: any) => this.handleIncomingEvent(e));
|
||||
sub.on("eose", () => resolve());
|
||||
|
||||
private async pubkeyAllowed(params: {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
method: any;
|
||||
params?: any;
|
||||
}): Promise<boolean> {
|
||||
return this.permitCallback(params);
|
||||
}
|
||||
|
||||
private async handleRequest(req: Nip46Request): Promise<void> {
|
||||
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<string | undefined> {
|
||||
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`
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 },
|
||||
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 ?? "",
|
||||
});
|
||||
|
||||
await prisma.token.update({
|
||||
where: { id: tokenRecord.id },
|
||||
data: {
|
||||
redeemedAt: new Date(),
|
||||
keyUserId: upsertedUser.id,
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
57
src/daemon/backend/token-store.ts
Normal file
57
src/daemon/backend/token-store.ts
Normal file
|
|
@ -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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -343,17 +343,31 @@ export class RelayPool {
|
|||
});
|
||||
}
|
||||
|
||||
/** Publish to every relay; resolves if at least one accepts it. */
|
||||
async publish(event: Event): Promise<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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")) {
|
||||
const reasons = results
|
||||
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. */
|
||||
connectedCount(): number {
|
||||
|
|
|
|||
162
src/daemon/nip46/transport.ts
Normal file
162
src/daemon/nip46/transport.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
50
src/daemon/nip46/types.ts
Normal file
50
src/daemon/nip46/types.ts
Normal file
|
|
@ -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<boolean>;
|
||||
|
||||
/** Hook to redeem a connection token (the bunker's `applyToken`). */
|
||||
export type Nip46ApplyTokenCallback = (pubkey: string, token: string) => Promise<void>;
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
|
@ -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<string, any>;
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
138
tests/nip46-backend.test.ts
Normal file
138
tests/nip46-backend.test.ts
Normal file
|
|
@ -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<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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue