From a676d4fa9833181bc40c6c200a64b86d7f9fb028 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 27 Jun 2026 01:23:53 +0200 Subject: [PATCH] feat(transport): port the admin RPC off NDK onto the relay pool (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third increment of the NDK -> nostr-tools transport swap. The admin interface's runtime RPC now runs on the RelayPool transport, so the admin channel — like the signer channel — re-subscribes on every relay reconnect and can't go silently deaf after a flap (#41). - nip46/transport.ts is now a full RPC: besides serving inbound requests it routes inbound RESPONSES to one-shot handlers (the pending map) and can sendRequest() — needed for the interactive approval flow (bunker -> operator "acl" request). start() takes the kinds to listen on; sendResponse() takes the response kind. The signer backend is unaffected (still one kind, response-only). - admin/index.ts: rebuilt on RelayPool + Nip46Transport instead of NDK + NDKNostrRpc + attachIndefiniteReconnect. `rpc` is a small adapter the command handlers keep calling; it resolves each request's envelope scheme (nip04/nip44) by id and publishes on the admin channel (24134). requestPermission/Response use transport.sendRequest + nip19 instead of NDKNostrRpc + NDKUser. The connectedRelays()-only watchdog is replaced by a session-liveness one on pool.healthy() (connected AND subscribed) — the check the old one couldn't make (#20/#41). - admin/types.ts: AdminRpcRequest / AdminRpc replace NDKRpcRequest / NDKNostrRpc. The ~16 command + validation handlers swap the import only (they use req.{id,pubkey,method,params,event.kind}); no logic change. - admin/kinds.ts: plain numeric kinds (24133/24134), no NDKKind type dep. - relay-reconnect.ts deleted — its job (reconnect) now lives in the pool, and its blind spot (no resubscribe) is exactly what #41 was. Still on NDK (not transport, addressed separately): the one-shot boot DM (notifyAdminsOnBoot, throwaway NDK over public relays), key generation in create_new_key/create_account, the getKeys npub helper, and the standalone CLI client (src/client.ts). Tests (tests/admin-transport.test.ts): a request on 24133 is answered on 24134 and survives a relay flap; the sendRequest + response-routing approval flow round- trips. lifecycle 7 / relay 2 / nip46 1 / admin 2 all green; daemon bundles clean; zero new type errors. Refs: #42, #41, #20, #7 --- package.json | 3 +- src/daemon/admin/commands/add_policy_rule.ts | 4 +- .../admin/commands/add_signing_condition.ts | 4 +- src/daemon/admin/commands/create_account.ts | 13 +- src/daemon/admin/commands/create_new_key.ts | 5 +- .../admin/commands/create_new_policy.ts | 4 +- src/daemon/admin/commands/create_new_token.ts | 4 +- src/daemon/admin/commands/ping.ts | 4 +- .../admin/commands/remove_policy_rule.ts | 4 +- .../commands/remove_signing_condition.ts | 4 +- src/daemon/admin/commands/rename_key_user.ts | 4 +- src/daemon/admin/commands/revoke_token.ts | 4 +- src/daemon/admin/commands/revoke_user.ts | 4 +- src/daemon/admin/commands/unlock_key.ts | 4 +- src/daemon/admin/commands/update_policy.ts | 4 +- .../admin/commands/update_policy_rule.ts | 4 +- src/daemon/admin/index.ts | 336 ++++++++---------- src/daemon/admin/kinds.ts | 20 +- src/daemon/admin/types.ts | 35 ++ .../admin/validations/request-from-admin.ts | 4 +- src/daemon/lib/relay-reconnect.ts | 101 ------ src/daemon/nip46/transport.ts | 95 +++-- src/daemon/nip46/types.ts | 3 + src/daemon/run.ts | 4 +- tests/admin-transport.test.ts | 133 +++++++ 25 files changed, 432 insertions(+), 372 deletions(-) create mode 100644 src/daemon/admin/types.ts delete mode 100644 src/daemon/lib/relay-reconnect.ts create mode 100644 tests/admin-transport.test.ts diff --git a/package.json b/package.json index 81bda4e..1526d9a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.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:admin": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/admin-transport.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:nip46 && npm run test:integration", + "test:all": "npm run test && npm run test:relay && npm run test:nip46 && npm run test:admin && 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/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts index 503dd17..76d5d83 100644 --- a/src/daemon/admin/commands/add_policy_rule.ts +++ b/src/daemon/admin/commands/add_policy_rule.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -23,7 +23,7 @@ import prisma from "../../../db.js"; * `rule.kind.toString()` storage and the override-layer convention. The * `'all'` literal is honored at sign-time as a wildcard across kinds. */ -export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) { +export default async function addPolicyRule(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/add_signing_condition.ts b/src/daemon/admin/commands/add_signing_condition.ts index f734d24..a9aebce 100644 --- a/src/daemon/admin/commands/add_signing_condition.ts +++ b/src/daemon/admin/commands/add_signing_condition.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -18,7 +18,7 @@ import prisma from "../../../db.js"; * checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here * denies regardless of the policy. */ -export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { +export default async function addSigningCondition(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_account.ts b/src/daemon/admin/commands/create_account.ts index 2919d9c..6a2d561 100644 --- a/src/daemon/admin/commands/create_account.ts +++ b/src/daemon/admin/commands/create_account.ts @@ -1,4 +1,5 @@ -import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk"; +import { Hexpubkey, NDKPrivateKeySigner, NDKUserProfile } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from ".."; import { nip19 } from 'nostr-tools'; import { setupSkeletonProfile } from "../../lib/profile"; @@ -69,7 +70,7 @@ const RESERVED_USERNAMES = [ "admin", "root", "_", "administrator", "__" ]; -async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: NDKRpcRequest) { +async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: AdminRpcRequest) { if (!username || username.length === 0) { // create a random username of 10 characters username = Math.random().toString(36).substring(2, 15); @@ -83,7 +84,7 @@ async function validateUsername(username: string | undefined, domain: string, ad return username; } -async function validateDomain(domain: string | undefined, admin: AdminInterface, req: NDKRpcRequest) { +async function validateDomain(domain: string | undefined, admin: AdminInterface, req: AdminRpcRequest) { const availableDomains = (await admin.config()).domains; if (!availableDomains || Object.keys(availableDomains).length === 0) @@ -99,7 +100,7 @@ async function validateDomain(domain: string | undefined, admin: AdminInterface, return domain; } -export default async function createAccount(admin: AdminInterface, req: NDKRpcRequest) { +export default async function createAccount(admin: AdminInterface, req: AdminRpcRequest) { let [ username, domain, email ] = req.params as [ string?, string?, string? ]; try { @@ -143,7 +144,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe */ export async function createAccountReal( admin: AdminInterface, - req: NDKRpcRequest, + req: AdminRpcRequest, username: string, domain: string, email?: string @@ -227,7 +228,7 @@ export async function createAccountReal( } } -async function grantPermissions(req: NDKRpcRequest, keyName: string) { +async function grantPermissions(req: AdminRpcRequest, keyName: string) { await allowAllRequestsFromKey(req.pubkey, keyName, "connect"); await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' }); await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt"); diff --git a/src/daemon/admin/commands/create_new_key.ts b/src/daemon/admin/commands/create_new_key.ts index cdedcd3..c9182ed 100644 --- a/src/daemon/admin/commands/create_new_key.ts +++ b/src/daemon/admin/commands/create_new_key.ts @@ -1,4 +1,5 @@ -import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk"; +import NDK, { NDKEvent, NDKPrivateKeySigner, type NostrEvent } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { saveEncrypted } from "../../../commands/add.js"; @@ -6,7 +7,7 @@ import { getCurrentConfig } from "../../../config/index.js"; import { decryptNsec } from "../../../config/keys.js"; import { setupSkeletonProfile } from "../../lib/profile.js"; -export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { +export default async function createNewKey(admin: AdminInterface, req: AdminRpcRequest) { const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ]; if (!keyName || !passphrase) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_new_policy.ts b/src/daemon/admin/commands/create_new_policy.ts index af4bfd4..3579a76 100644 --- a/src/daemon/admin/commands/create_new_policy.ts +++ b/src/daemon/admin/commands/create_new_policy.ts @@ -1,9 +1,9 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) { +export default async function createNewPolicy(admin: AdminInterface, req: AdminRpcRequest) { const [ _policy ] = req.params as [ string ]; if (!_policy) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/create_new_token.ts b/src/daemon/admin/commands/create_new_token.ts index b66765f..97c711e 100644 --- a/src/daemon/admin/commands/create_new_token.ts +++ b/src/daemon/admin/commands/create_new_token.ts @@ -1,9 +1,9 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) { +export default async function createNewToken(admin: AdminInterface, req: AdminRpcRequest) { const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ]; if (!clientName || !policyId) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/ping.ts b/src/daemon/admin/commands/ping.ts index 9368c44..6abdb11 100644 --- a/src/daemon/admin/commands/ping.ts +++ b/src/daemon/admin/commands/ping.ts @@ -1,7 +1,7 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; -export default async function ping(admin: AdminInterface, req: NDKRpcRequest) { +export default async function ping(admin: AdminInterface, req: AdminRpcRequest) { return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND); } diff --git a/src/daemon/admin/commands/remove_policy_rule.ts b/src/daemon/admin/commands/remove_policy_rule.ts index f8b7c60..07836c3 100644 --- a/src/daemon/admin/commands/remove_policy_rule.ts +++ b/src/daemon/admin/commands/remove_policy_rule.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * removes across instance versions can race. Adds are safe, removes * are not. */ -export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { +export default async function removePolicyRule(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/remove_signing_condition.ts b/src/daemon/admin/commands/remove_signing_condition.ts index 0e4da78..661851f 100644 --- a/src/daemon/admin/commands/remove_signing_condition.ts +++ b/src/daemon/admin/commands/remove_signing_condition.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -10,7 +10,7 @@ import prisma from "../../../db.js"; * Param shape (JSON-stringified): * { conditionId: number } */ -export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { +export default async function removeSigningCondition(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/rename_key_user.ts b/src/daemon/admin/commands/rename_key_user.ts index 0877cf4..fa9e561 100644 --- a/src/daemon/admin/commands/rename_key_user.ts +++ b/src/daemon/admin/commands/rename_key_user.ts @@ -1,9 +1,9 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) { +export default async function renameKeyUser(admin: AdminInterface, req: AdminRpcRequest) { const [ keyUserPubkey, name ] = req.params as [ string, string ]; if (!keyUserPubkey || !name) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/revoke_token.ts b/src/daemon/admin/commands/revoke_token.ts index db04c21..8993fae 100644 --- a/src/daemon/admin/commands/revoke_token.ts +++ b/src/daemon/admin/commands/revoke_token.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * bound to it continue to grant via their own policies. Use * revoke_user for the binary "this user is gone" case. */ -export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) { +export default async function revokeToken(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/revoke_user.ts b/src/daemon/admin/commands/revoke_user.ts index 9deb5b6..4477cba 100644 --- a/src/daemon/admin/commands/revoke_user.ts +++ b/src/daemon/admin/commands/revoke_user.ts @@ -1,9 +1,9 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; -export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) { +export default async function revokeUser(admin: AdminInterface, req: AdminRpcRequest) { const [ keyUserId ] = req.params as [ string ]; if (!keyUserId) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/unlock_key.ts b/src/daemon/admin/commands/unlock_key.ts index dc27f39..dd2d174 100644 --- a/src/daemon/admin/commands/unlock_key.ts +++ b/src/daemon/admin/commands/unlock_key.ts @@ -1,8 +1,8 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; -export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) { +export default async function unlockKey(admin: AdminInterface, req: AdminRpcRequest) { const [ keyName, passphrase ] = req.params as [ string, string ]; if (!keyName || !passphrase) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/update_policy.ts b/src/daemon/admin/commands/update_policy.ts index ebc9805..78e1045 100644 --- a/src/daemon/admin/commands/update_policy.ts +++ b/src/daemon/admin/commands/update_policy.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -17,7 +17,7 @@ import prisma from "../../../db.js"; * `expiresAt: null` explicitly clears the field; `expiresAt` absent * from the patch leaves it alone. */ -export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) { +export default async function updatePolicy(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/commands/update_policy_rule.ts b/src/daemon/admin/commands/update_policy_rule.ts index 9b66bcf..4e054d0 100644 --- a/src/daemon/admin/commands/update_policy_rule.ts +++ b/src/daemon/admin/commands/update_policy_rule.ts @@ -1,4 +1,4 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import prisma from "../../../db.js"; @@ -23,7 +23,7 @@ import prisma from "../../../db.js"; * Tightening a cap takes effect immediately — a client already over the new * limit within the window is denied until its trailing count falls below it. */ -export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { +export default async function updatePolicyRule(admin: AdminInterface, req: AdminRpcRequest) { const [ _payload ] = req.params as [ string ]; if (!_payload) throw new Error("Invalid params"); diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index 1354685..633b050 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -1,6 +1,5 @@ -import "websocket-polyfill"; -import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk'; -import { NDKNostrRpc } from '@nostr-dev-kit/ndk'; +import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; +import { getPublicKey, nip19 } from 'nostr-tools'; import createDebug from 'debug'; import { Key, KeyUser } from '../run'; import { allowAllRequestsFromKey } from '../lib/acl/index.js'; @@ -21,12 +20,16 @@ import addSigningCondition from './commands/add_signing_condition'; import removeSigningCondition from './commands/remove_signing_condition'; import revokeToken from './commands/revoke_token'; import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js'; +import { NIP46_NOSTR_CONNECT_KIND } from './kinds.js'; import fs from 'fs'; import { validateRequestFromAdmin } from './validations/request-from-admin'; import { dmUser } from '../../utils/dm-user'; import { IConfig, getCurrentConfig } from "../../config"; import path from 'path'; -import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js'; +import { RelayPool } from '../lib/relay-pool.js'; +import { Nip46Transport, secretKeyBytes } from '../nip46/transport.js'; +import type { AdminRpc, AdminRpcRequest } from './types.js'; +import type { Nip46Request } from '../nip46/types.js'; const debug = createDebug("nsecbunker:admin"); @@ -45,73 +48,91 @@ const allowNewKeys = true; * This class represents the admin interface for the nsecbunker daemon. * * It provides an interface for a UI to manage the daemon over nostr. + * + * Ported off NDK onto the nostr-tools RelayPool transport (aiolabs/nsecbunkerd#42) + * so the admin channel, like the signer channel, re-subscribes on every relay + * reconnect and can't go silently deaf after a flap (#41). */ class AdminInterface { private npubs: string[]; - private ndk: NDK; - private signerUser?: NDKUser; - readonly rpc: NDKNostrRpc; + private pool: RelayPool; + private transport: Nip46Transport; + private adminPubkey: string; + private adminNsec: string; + /** Envelope encryption (nip04/nip44) of each in-flight request, by id, so + * responses go back in the scheme the client used. */ + private reqEncryption: Map = new Map(); + readonly rpc: AdminRpc; readonly configFile: string; public getKeys?: () => Promise; - public getKeyUsers?: (req: NDKRpcRequest) => Promise; + public getKeyUsers?: (req: AdminRpcRequest) => Promise; public unlockKey?: (keyName: string, passphrase: string) => Promise; public loadNsec?: (keyName: string, nsec: string) => void; constructor(opts: IAdminOpts, configFile: string) { this.configFile = configFile; - this.npubs = opts.npubs||[]; - this.ndk = new NDK({ - explicitRelayUrls: opts.adminRelays, - signer: new NDKPrivateKeySigner(opts.key), + this.npubs = opts.npubs || []; + this.adminNsec = opts.key; + this.adminPubkey = getPublicKey(secretKeyBytes(opts.key)); + + this.pool = new RelayPool(opts.adminRelays, { + log: (...a: any[]) => console.log(...a), + heartbeatMs: 30_000, }); + this.transport = new Nip46Transport(secretKeyBytes(opts.key), this.pool); - // Override NDK's "give up after detecting flapping" behavior so the - // bunker's admin NDK keeps trying to reconnect indefinitely. The - // watchdog (when enabled) still fires after 60s of zero connected - // relays; this helper handles shorter disconnects (e.g. an lnbits - // restart that pulls the nostrrelay extension's WS for a few - // seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20. - attachIndefiniteReconnect(this.ndk, 'admin'); + // The admin RPC the command handlers call. sendResponse encrypts with + // the scheme the request used (resolved per id) and publishes on the + // admin response channel (24134) unless a handler mirrors the request + // kind for errors. + this.rpc = { + sendResponse: async ( + id: string, + remotePubkey: string, + result: string, + kind: number = NIP46_NOSTR_CONNECT_KIND, + error?: string, + ) => { + const encryption = this.reqEncryption.get(id) ?? "nip44"; + await this.transport.sendResponse(id, remotePubkey, result, encryption, error, kind); + }, + }; - this.ndk.signer?.user().then((user: NDKUser) => { - let connectionString = `bunker://${user.npub}`; + const npub = nip19.npubEncode(this.adminPubkey); + let connectionString = `bunker://${npub}`; + if (opts.adminRelays.length > 0) { + connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`); + } + console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`); + const configFolder = path.dirname(configFile); + fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString); - if (opts.adminRelays.length > 0) { - connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`); + this.connect(); + + this.config().then((config) => { + if (config.admin?.notifyAdminsOnBoot) { + this.notifyAdminsOfNewConnection(connectionString); } - - console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`); - - // write connection string to connection.txt - const configFolder = path.dirname(configFile) - fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString); - - this.signerUser = user; - - this.connect(); - - this.config().then((config) => { - if (config.admin?.notifyAdminsOnBoot) { - this.notifyAdminsOfNewConnection(connectionString); - } - }); }); - - this.rpc = new NDKNostrRpc(this.ndk, this.ndk.signer!, debug); } public async config(): Promise { return getCurrentConfig(this.configFile); } + /** + * Boot-time DM to the admin npubs. One-shot, best-effort notification over + * public relays — not part of the reconnect-sensitive RPC path, so it still + * uses a throwaway NDK + the existing dmUser helper. (#42 leaves this on NDK.) + */ private async notifyAdminsOfNewConnection(connectionString: string) { const blastrNdk = new NDK({ explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'], - signer: this.ndk.signer + signer: new NDKPrivateKeySigner(this.adminNsec), }); await blastrNdk.connect(2500); - for (const npub of this.npubs||[]) { + for (const npub of this.npubs || []) { dmUser(blastrNdk, npub, `nsecBunker has started; use ${connectionString} to connect to it and unlock your key(s)`); } } @@ -120,7 +141,7 @@ class AdminInterface { * Get the npub of the admin interface. */ public async npub() { - return (await this.ndk.signer?.user())!.npub; + return nip19.npubEncode(this.adminPubkey); } private connect() { @@ -129,81 +150,59 @@ class AdminInterface { return; } - const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1'; - - // Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7. - // NDKNostrRpc.sendResponse calls event.publish() and discards the - // returned Set, so a silent outbox-drop is invisible without - // hooking the underlying per-relay events. Gated by env flag so - // production deployments stay quiet. - const attachRelayLogging = (relay: any) => { - relay.on('published', (event: NDKEvent) => { - console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`); - }); - relay.on('publish:failed', (event: NDKEvent, err: any) => { - console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`); - }); - }; - - this.ndk.pool.on('relay:connect', (relay: any) => { - console.log('✅ nsecBunker Admin Interface ready'); - if (debugTransport) attachRelayLogging(relay); - }); - this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected')); - - this.ndk.connect(2500).then(() => { - // connect for whitelisted admins - this.rpc.subscribe({ - "kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND], - "#p": [this.signerUser!.pubkey] + this.pool.start(); + // Listen for admin requests on the NostrConnect channel (24133) AND the + // admin response channel (24134, where admin clients address us). + this.transport + .start((req) => this.onRequest(req), [NIP46_NOSTR_CONNECT_KIND, NIP46_ADMIN_RESPONSE_KIND]) + .then(() => console.log('✅ nsecBunker Admin Interface ready')) + .catch((err) => { + console.log('❌ admin transport failed'); + console.log(err); }); - // Attach per-relay logging to relays that connected before our - // 'relay:connect' listener was registered above (NDK can connect - // synchronously inside .connect() under some paths). - if (debugTransport) { - this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay)); - - // Wrap sendResponse to log id + kind + elapsed time so we - // can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED. - const originalSendResponse = this.rpc.sendResponse.bind(this.rpc); - this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => { - const start = Date.now(); - try { - await originalSendResponse(id, remotePubkey, result, kind, error); - console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`); - } catch (e: any) { - console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`); - throw e; - } - }; - } - - this.rpc.on('request', (req) => { - if (debugTransport) { - console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`); - } - this.handleRequest(req); - }); - - // Connection watchdog: exit if pool reports no connected relays - // for >60s so the process supervisor (systemd / docker restart - // policy / k8s) can recover. Replaces the original self-echo - // pingOrDie — see relayConnectionWatchdog comment + #4 + #7. - // Operators with external liveness checking can disable via - // NSEC_BUNKER_DISABLE_WATCHDOG=1. - if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') { - relayConnectionWatchdog(this.ndk); - } else { - console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1'); - } - }).catch((err) => { - console.log('❌ admin connection failed'); - console.log(err); - }); + // Session-liveness watchdog: exit (so the process supervisor restarts) + // if the admin pool can't stay healthy — connected AND subscribed — for + // >60s. Unlike the old connectedRelays()-only watchdog (#20), this can't + // be fooled by a reconnected-but-deaf socket (#41). Disable via + // NSEC_BUNKER_DISABLE_WATCHDOG=1. + if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') { + this.startWatchdog(); + } else { + console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1'); + } } - private async handleRequest(req: NDKRpcRequest) { + private startWatchdog() { + const POLL_INTERVAL_MS = 10_000; + const UNHEALTHY_THRESHOLD_MS = 60_000; + let lastHealthyAt = Date.now(); + setInterval(() => { + if (this.pool.healthy()) { + lastHealthyAt = Date.now(); + return; + } + const elapsed = Date.now() - lastHealthyAt; + if (elapsed > UNHEALTHY_THRESHOLD_MS) { + console.log(`❌ Admin pool unhealthy for ${Math.floor(elapsed / 1000)}s. Exiting.`); + process.exit(1); + } + }, POLL_INTERVAL_MS); + } + + private onRequest(req: Nip46Request) { + const adminReq: AdminRpcRequest = { + id: req.id, + pubkey: req.remotePubkey, + method: req.method, + params: req.params, + event: { kind: req.kind }, + }; + this.reqEncryption.set(req.id, req.encryption); + void this.handleRequest(adminReq).finally(() => this.reqEncryption.delete(req.id)); + } + + private async handleRequest(req: AdminRpcRequest) { try { await this.validateRequest(req); @@ -228,30 +227,25 @@ class AdminInterface { case 'remove_signing_condition': await removeSigningCondition(this, req); break; case 'revoke_token': await revokeToken(this, req); break; default: - const originalKind = req.event.kind!; console.log(`Unknown method ${req.method}`); return this.rpc.sendResponse( req.id, req.pubkey, JSON.stringify(['error', `Unknown method ${req.method}`]), - originalKind + req.event.kind, ); } } catch (err: any) { - debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params); - // NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it - // makes sendResponse fall through to its default of 24133, which - // sends the error on a different channel than the request came in - // on. Mirror req.event.kind so the response goes back where the - // client is listening. Filed as part of aiolabs/nsecbunkerd#7 - // diagnosis 2026-05-27. - const originalKind = req.event.kind!; + debug(`Error handling request ${req.method}: ${err?.message ?? err}`, req.params); + // Mirror req.event.kind so the error goes back on the channel the + // request came in on (aiolabs/nsecbunkerd#7). + const originalKind = req.event.kind; console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`); return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message); } } - private async validateRequest(req: NDKRpcRequest): Promise { + private async validateRequest(req: AdminRpcRequest): Promise { // if this request is of type create_account, allow it // TODO: require some POW to prevent spam if (req.method === 'create_account' && allowNewKeys) { @@ -267,7 +261,7 @@ class AdminInterface { /** * Command to list tokens */ - private async reqGetKeyTokens(req: NDKRpcRequest) { + private async reqGetKeyTokens(req: AdminRpcRequest) { const keyName = req.params[0]; const tokens = await prisma.token.findMany({ where: { keyName }, @@ -295,7 +289,7 @@ class AdminInterface { id: t.id, key_name: t.keyName, client_name: t.clientName, - token: [ npub, t.token ].join('#'), + token: [npub, t.token].join('#'), policy_id: t.policyId, policy_name: t.policy?.name, created_at: t.createdAt, @@ -313,7 +307,7 @@ class AdminInterface { /** * Command to list policies */ - private async reqListPolicies(req: NDKRpcRequest) { + private async reqListPolicies(req: AdminRpcRequest) { const policies = await prisma.policy.findMany({ include: { rules: true, @@ -346,7 +340,7 @@ class AdminInterface { /** * Command to fetch keys and their current state */ - private async reqGetKeys(req: NDKRpcRequest) { + private async reqGetKeys(req: AdminRpcRequest) { if (!this.getKeys) throw new Error('getKeys() not implemented'); const result = JSON.stringify(await this.getKeys()); @@ -358,7 +352,7 @@ class AdminInterface { /** * Command to fetch users of a key */ - private async reqGetKeyUsers(req: NDKRpcRequest): Promise { + private async reqGetKeyUsers(req: AdminRpcRequest): Promise { if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented'); const result = JSON.stringify(await this.getKeyUsers(req)); @@ -388,36 +382,29 @@ class AdminInterface { }, }); - console.trace({method, param}); - if (method === 'sign_event') { - const e = param.rawEvent(); + // `param` is the parsed event object the signer passed to the ACL + // (a plain event under #42, no longer an NDKEvent.rawEvent()). + const e = param; param = JSON.stringify(e); console.log(`👀 Event to be signed\n`, { - kind: e.kind, - content: e.content, - tags: e.tags, + kind: e?.kind, + content: e?.content, + tags: e?.tags, }); } - return new Promise((resolve, reject) => { - console.log(`requesting permission for`, keyName); - console.log(`remotePubkey`, remotePubkey); - console.log(`method`, method); - console.log(`param`, param); - console.log(`keyUser`, keyUser); + return new Promise((resolve) => { + console.log(`requesting permission for`, keyName, { remotePubkey, method }); - /** - * If an admin doesn't respond within 10 seconds, report back to the user that the request timed out - */ + // If an admin doesn't respond within 10 seconds, report timeout. setTimeout(() => { resolve(undefined); }, 10000); for (const npub of this.npubs) { - const remoteUser = new NDKUser({npub}); - console.log(`sending request to ${npub}`, remoteUser.pubkey); + const adminPubkey = nip19.decode(npub).data as string; const params = JSON.stringify({ keyName, remotePubkey, @@ -426,12 +413,14 @@ class AdminInterface { description: keyUser?.description, }); - this.rpc.sendRequest( - remoteUser.pubkey, + const id = this.transport.sendRequest( + adminPubkey, 'acl', [params], + 'nip44', NIP46_ADMIN_RESPONSE_KIND, - (res: NDKRpcResponse) => { + (res) => { + this.transport.clearPending(id); this.requestPermissionResponse( remotePubkey, keyName, @@ -452,7 +441,7 @@ class AdminInterface { method: string, param: string, resolve: (value: boolean) => void, - res: NDKRpcResponse + res: { id: string; result: string; error?: string } ) { let resObj; try { @@ -485,47 +474,4 @@ class AdminInterface { } } -/** - * Pool-status connection watchdog. Exits the daemon if every relay in - * the pool stays disconnected for longer than PARTITION_THRESHOLD_MS. - * - * Replaces the original `pingOrDie` self-echo watchdog, which published - * a kind-24133 event to its own pubkey every 20s and exited if it - * didn't see the echo within 50s. That works on public relays but - * silently breaks on single-private-relay setups: NDK 2.8.1's outbox - * model doesn't reliably route self-publishes back through the - * matching subscription, so the watchdog fires false positives and - * exits the daemon every 50s while RPCs over the same channel still - * work fine. See aiolabs/nsecbunkerd#4 + #7. - * - * The pool-status approach uses NDK's own connection-lifecycle - * tracking — `pool.connectedRelays()` reports relays in - * NDKRelayStatus.CONNECTED — which is reliable across all relay - * configurations because it doesn't depend on round-trip - * publish/subscribe. No event is published; no relay traffic. - * - * Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms. - * Transient disconnects shorter than PARTITION_THRESHOLD don't trip - * the watchdog — useful for relays that flap or briefly drop on - * network blips. - */ -async function relayConnectionWatchdog(ndk: NDK) { - const POLL_INTERVAL_MS = 10_000; - const PARTITION_THRESHOLD_MS = 60_000; - let lastConnectedAt = Date.now(); - - setInterval(() => { - const connectedCount = ndk.pool.connectedRelays().length; - if (connectedCount > 0) { - lastConnectedAt = Date.now(); - return; - } - const elapsed = Date.now() - lastConnectedAt; - if (elapsed > PARTITION_THRESHOLD_MS) { - console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`); - process.exit(1); - } - }, POLL_INTERVAL_MS); -} - export default AdminInterface; diff --git a/src/daemon/admin/kinds.ts b/src/daemon/admin/kinds.ts index 85ba137..60df753 100644 --- a/src/daemon/admin/kinds.ts +++ b/src/daemon/admin/kinds.ts @@ -1,14 +1,12 @@ -import type { NDKKind } from '@nostr-dev-kit/ndk'; +/** + * NIP-46 client channel — kind-24133. Carries `connect` / `sign_event` / + * `nip04_*` / `nip44_*` etc. (NDK called this `NDKKind.NostrConnect`.) + */ +export const NIP46_NOSTR_CONNECT_KIND = 24133; /** - * NIP-46 admin-RPC response channel — kind-24134. Distinct from the - * standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`) - * which carries `sign_event` / `nip04_*` / `nip44_*` / etc. - * - * nsecbunkerd's admin surface uses a dedicated kind so signer clients - * and admin clients don't subscribe to each other's events. - * - * NDK 3.x's `NDKKind` enum does not include 24134; the cast happens - * once here so callers can pass a typed value to `rpc.sendResponse`. + * NIP-46 admin-RPC response channel — kind-24134. Distinct from the client + * channel (24133) so signer clients and admin clients don't subscribe to each + * other's events. */ -export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind; +export const NIP46_ADMIN_RESPONSE_KIND = 24134; diff --git a/src/daemon/admin/types.ts b/src/daemon/admin/types.ts new file mode 100644 index 0000000..d450b86 --- /dev/null +++ b/src/daemon/admin/types.ts @@ -0,0 +1,35 @@ +/** + * Admin-RPC types (aiolabs/nsecbunkerd#42). + * + * Replace NDK's `NDKRpcRequest` / `NDKNostrRpc` so the admin interface — like + * the signer backend — runs on the nostr-tools RelayPool transport instead of + * NDK. Shaped to match what the admin command handlers already use + * (`req.{id,pubkey,method,params,event.kind}`), so the handlers are unchanged + * apart from the import. + */ + +export interface AdminRpcRequest { + id: string; + /** The verified sender (admin client) pubkey, hex. */ + pubkey: string; + method: string; + params: string[]; + /** The inbound event — handlers read `event.kind` to mirror the channel. */ + event: { kind: number }; +} + +/** + * The subset of NDKNostrRpc the admin command handlers call. Backed by the + * Nip46Transport; `sendResponse` encrypts the reply with the same scheme the + * request used (resolved per request id) and publishes it on `kind` (the admin + * response channel 24134, or the mirrored request kind for errors). + */ +export interface AdminRpc { + sendResponse( + id: string, + remotePubkey: string, + result: string, + kind?: number, + error?: string, + ): Promise; +} diff --git a/src/daemon/admin/validations/request-from-admin.ts b/src/daemon/admin/validations/request-from-admin.ts index 38ccd04..963bd7f 100644 --- a/src/daemon/admin/validations/request-from-admin.ts +++ b/src/daemon/admin/validations/request-from-admin.ts @@ -1,8 +1,8 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; +import { AdminRpcRequest } from "../types.js"; import { nip19 } from "nostr-tools"; export async function validateRequestFromAdmin( - req: NDKRpcRequest, + req: AdminRpcRequest, npubs: string[], ): Promise { const hexpubkey = req.pubkey; diff --git a/src/daemon/lib/relay-reconnect.ts b/src/daemon/lib/relay-reconnect.ts deleted file mode 100644 index e3fdb4f..0000000 --- a/src/daemon/lib/relay-reconnect.ts +++ /dev/null @@ -1,101 +0,0 @@ -import NDK from "@nostr-dev-kit/ndk"; - -/** - * Attaches an aggressive-reconnect supervisor to an NDK instance. - * - * NDK 3.x's per-relay connectivity state machine gives up retrying after - * a few consecutive fast-fail (e.g. ECONNREFUSED returns in <1 ms) - * connection attempts: - * - * 1. Each attempt's duration is recorded in `_connectionStats.durations`. - * 2. After every 3 attempts, `isFlapping()` checks the std-dev of those - * durations against `FLAPPING_THRESHOLD_MS` (1 second). Three fast - * failures look identical → tiny std-dev → flapping=true → status - * transitions to FLAPPING and the per-relay retry stops. - * 3. `NDKPool.handleFlapping` catches the event and reschedules a - * reconnect via doubling backoff (5s → 10s → 20s → 40s → 80s …), - * growing unbounded. - * - * For nsecbunkerd, where the admin relay is typically a single relay we - * **must** stay subscribed to, "disconnected for 80+s after every dev - * restart" is the failure mode users hit (aiolabs/nsecbunkerd#20). The - * pool's doubling backoff is too pessimistic for our use case. - * - * This helper sidesteps the give-up path: when the pool emits `flapping` - * (the symptom that NDK has internally given up), or when we see the - * relay disconnect outside our own request, we manually call - * `relay.connect()` with a SHORT capped delay. Successful connect resets - * the attempt counter so a future disconnect storm doesn't grow the - * delay. - * - * Trade-off: we may hammer a permanently-down relay every 10s. That's - * fine for a bunker — being disconnected silently is strictly worse than - * a retry storm against localhost. Acceptable because: - * - The bunker's primary relay is typically on the same host or LAN - * (`ws://lnbits:5001/...`); TCP RSTs are cheap. - * - Public-relay setups can layer external supervision on top if they - * care about retry pressure. - */ -export function attachIndefiniteReconnect(ndk: NDK, label: string): void { - const RECONNECT_BASE_MS = 1_000; - const RECONNECT_CAP_MS = 10_000; - - const attempts = new Map(); - const pending = new Map(); - - const reconnectDelay = (n: number): number => - Math.min(RECONNECT_BASE_MS * 2 ** n, RECONNECT_CAP_MS); - - const scheduleReconnect = (relay: any): void => { - const url: string = relay.url; - if (pending.has(url)) return; - const n = attempts.get(url) ?? 0; - const delay = reconnectDelay(n); - console.log( - `🔁 ${label}: scheduling reconnect to ${url} in ${delay}ms ` + - `(attempt ${n + 1}, overriding NDK give-up)` - ); - const timer = setTimeout(() => { - pending.delete(url); - attempts.set(url, n + 1); - relay.connect().catch((e: any) => { - console.log( - `❌ ${label}: manual reconnect to ${url} failed: ` + - `${e?.message ?? e}` - ); - // Don't recurse here — the next 'flapping' or 'disconnect' - // event will fire and schedule another attempt. - }); - }, delay); - pending.set(url, timer); - }; - - ndk.pool.on("flapping", (relay: any) => { - console.log( - `⚠️ ${label}: NDK flagged ${relay.url} as flapping ` + - `(connectivity machine gave up internally)` - ); - scheduleReconnect(relay); - }); - - ndk.pool.on("relay:disconnect", (relay: any) => { - scheduleReconnect(relay); - }); - - ndk.pool.on("relay:connect", (relay: any) => { - const url: string = relay.url; - const n = attempts.get(url) ?? 0; - if (n > 0) { - console.log( - `✅ ${label}: recovered ${url} after ${n} manual reconnect ` + - `attempt(s)` - ); - } - attempts.delete(url); - const timer = pending.get(url); - if (timer) { - clearTimeout(timer); - pending.delete(url); - } - }); -} diff --git a/src/daemon/nip46/transport.ts b/src/daemon/nip46/transport.ts index 0b6dd6e..b787cee 100644 --- a/src/daemon/nip46/transport.ts +++ b/src/daemon/nip46/transport.ts @@ -38,6 +38,9 @@ export function secretKeyBytes(key: string): Uint8Array { export class Nip46Transport { public readonly pubkey: string; private sub: { close: () => void } | null = null; + /** Callbacks awaiting a response to one of our outbound sendRequest()s, by + * request id (the admin approval flow). */ + private pending = new Map void>(); constructor( private readonly sk: Uint8Array, @@ -47,15 +50,13 @@ export class Nip46Transport { 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 { + /** Start listening for this key's requests on the given kinds (the signer + * channel is [24133]; the admin channel is [24133, 24134]). Resolves once + * the subscription's first EOSE lands (the #9 start-race guard). */ + async start(onRequest: (req: Nip46Request) => void, kinds: number[] = [NIP46_KIND]): Promise { this.sub = await this.pool.subscribeAwaitingEose( - [{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], - (event: Event) => { - const req = this.parse(event); - if (req) onRequest(req); - }, + [{ kinds, "#p": [this.pubkey] }], + (event: Event) => this.onEvent(event, onRequest), { id: `nip46:${this.pubkey}` }, ); } @@ -65,9 +66,30 @@ export class Nip46Transport { 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 { + private onEvent(event: Event, onRequest: (req: Nip46Request) => void): void { + const env = this.parseEnvelope(event); + if (!env) return; + const { body, encryption, remotePubkey, kind } = env; + if (body.method) { + onRequest({ + id: body.id, + method: body.method, + params: body.params ?? [], + remotePubkey, + encryption, + kind, + }); + } else if (body.id !== undefined) { + // a response to one of our outbound requests (admin approval flow) + this.pending.get(body.id)?.({ id: body.id, result: body.result, error: body.error }); + } + } + + /** Verify + decrypt an inbound event into its JSON body + envelope metadata, + * or null if unreadable. Adaptive nip04/nip44 like NDKNostrRpc.parseEvent. */ + private parseEnvelope( + event: Event, + ): { body: any; encryption: "nip04" | "nip44"; remotePubkey: string; kind: number } | null { if (!verifyEvent(event)) { this.log("dropping event with invalid signature", event.id); return null; @@ -83,42 +105,63 @@ export class Nip46Transport { try { decrypted = this.decrypt(remotePubkey, event.content, encryption); } catch (e) { - this.log("failed to decrypt request", e); + this.log("failed to decrypt event", e); return null; } } - - let parsed: any; try { - parsed = JSON.parse(decrypted); + return { body: JSON.parse(decrypted), encryption, remotePubkey, kind: event.kind }; } catch { - this.log("request content was not JSON"); + this.log("event 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. */ + /** + * Send a NIP-46 request to a peer and register a one-shot response handler + * (the admin approval flow: bunker -> operator "acl" request). Returns the + * request id so the caller can `clearPending(id)` on timeout. + */ + sendRequest( + remotePubkey: string, + method: string, + params: string[], + encryption: "nip04" | "nip44", + kind: number, + cb: (res: { id: string; result: string; error?: string }) => void, + ): string { + const id = Math.random().toString(36).substring(2, 12); + this.pending.set(id, cb); + const content = this.encrypt(remotePubkey, JSON.stringify({ id, method, params }), encryption); + const event = finalizeEvent( + { kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", remotePubkey]], content }, + this.sk, + ); + void this.pool.publish(event); + return id; + } + + clearPending(id: string): void { + this.pending.delete(id); + } + + /** Encrypt + sign + publish a NIP-46 response, matching the request's scheme. + * `kind` defaults to the signer channel (24133); the admin RPC passes its + * own response kind (24134) or mirrors the request kind for errors. */ async sendResponse( id: string, remotePubkey: string, result: string, encryption: "nip04" | "nip44", error?: string, + kind: number = NIP46_KIND, ): 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, + kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", remotePubkey]], content, diff --git a/src/daemon/nip46/types.ts b/src/daemon/nip46/types.ts index 1609098..33e36c7 100644 --- a/src/daemon/nip46/types.ts +++ b/src/daemon/nip46/types.ts @@ -47,4 +47,7 @@ export interface Nip46Request { remotePubkey: string; /** Which envelope encryption the client used; the response must match it. */ encryption: "nip04" | "nip44"; + /** The kind of the inbound event (24133 signer / 24134 admin) — lets a + * handler mirror the request's channel when responding. */ + kind: number; } diff --git a/src/daemon/run.ts b/src/daemon/run.ts index f4ec4a7..3ba08a0 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -7,7 +7,7 @@ import type { Nip46PermitCallback, Nip46PermitCallbackParams } from './nip46/typ import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js'; import AdminInterface from './admin/index.js'; import { IConfig } from '../config/index.js'; -import { NDKRpcRequest } from '@nostr-dev-kit/ndk'; +import type { AdminRpcRequest } from './admin/types.js'; import prisma from '../db.js'; import { DaemonConfig } from './index.js'; import { decryptNsec } from '../config/keys.js'; @@ -59,7 +59,7 @@ function getKeys(config: DaemonConfig) { } function getKeyUsers(config: IConfig) { - return async (req: NDKRpcRequest): Promise => { + return async (req: AdminRpcRequest): Promise => { const keyUsers: KeyUser[] = []; const keyName = req.params[0]; diff --git a/tests/admin-transport.test.ts b/tests/admin-transport.test.ts new file mode 100644 index 0000000..88536e3 --- /dev/null +++ b/tests/admin-transport.test.ts @@ -0,0 +1,133 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateSecretKey, getPublicKey, finalizeEvent, nip44, type Event } from "nostr-tools"; +import { MockRelay } from "./helpers/mock-relay"; +import { RelayPool } from "../src/daemon/lib/relay-pool"; +import { Nip46Transport } from "../src/daemon/nip46/transport"; + +/** + * The admin interface adds two things to the transport over the signer backend + * (#42): it listens on TWO kinds (24133 client + 24134 admin) and replies on a + * chosen kind, and it can *send* requests + match responses (the interactive + * approval flow, `rpc.sendRequest`). This exercises both directions over the + * pool and across a relay flap. + */ + +const NOSTR_CONNECT = 24133; +const ADMIN_RESPONSE = 24134; + +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)); + } +} + +/** Raw NIP-46 client: send a request on `kind`, read the response on either + * channel addressed back to us. nip44 envelope. */ +class RawClient { + readonly sk = generateSecretKey(); + readonly pubkey = getPublicKey(this.sk); + private pending = new Map void>(); + private n = 0; + + constructor(private readonly pool: RelayPool, private readonly peer: string) { + this.pool.subscribe([{ kinds: [NOSTR_CONNECT, ADMIN_RESPONSE], "#p": [this.pubkey] }], (e: Event) => { + const m = JSON.parse(nip44.decrypt(e.content, nip44.getConversationKey(this.sk, e.pubkey))); + if (m.method) return; // we only consume responses here + this.pending.get(m.id)?.({ result: m.result, error: m.error }); + }); + } + + request(method: string, params: string[] = [], kind = NOSTR_CONNECT, timeoutMs = 5000) { + const id = `c${++this.n}`; + const ck = nip44.getConversationKey(this.sk, this.peer); + const ev = finalizeEvent( + { kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.peer]], content: nip44.encrypt(JSON.stringify({ id, method, params }), ck) }, + this.sk, + ); + return new Promise<{ result: string; error?: string }>((res, rej) => { + const t = setTimeout(() => rej(new Error(`${method} timed out`)), timeoutMs); + this.pending.set(id, (r) => { clearTimeout(t); res(r); }); + void this.pool.publish(ev); + }); + } +} + +test("admin transport: request on 24133 -> reply on 24134, survives a flap (#42)", async () => { + const relay = new MockRelay(); + await relay.start(); + + const adminSk = generateSecretKey(); + const adminPubkey = getPublicKey(adminSk); + + const adminPool = new RelayPool([relay.url], { log: () => {} }); + adminPool.start(); + const admin = new Nip46Transport(adminSk, adminPool); + // Echo handler: reply "ok" on the admin channel (24134), like the ping cmd. + await admin.start((req) => { + void admin.sendResponse(req.id, req.remotePubkey, "ok", req.encryption, undefined, ADMIN_RESPONSE); + }, [NOSTR_CONNECT, ADMIN_RESPONSE]); + + const clientPool = new RelayPool([relay.url], { log: () => {} }); + clientPool.start(); + await waitFor(() => clientPool.connectedCount() === 1 && adminPool.healthy()); + const client = new RawClient(clientPool, adminPubkey); + + assert.equal((await client.request("ping")).result, "ok"); + + await relay.flap(); + await waitFor(() => adminPool.healthy() && clientPool.healthy()); + assert.equal((await client.request("ping")).result, "ok", "admin still serving after flap"); + + admin.stop(); + adminPool.stop(); + clientPool.stop(); + await relay.stop(); +}); + +test("admin transport: sendRequest + response routing (the approval flow) (#42)", async () => { + const relay = new MockRelay(); + await relay.start(); + + // "admin" (bunker) and "operator" (the human's client) — each a transport. + const adminSk = generateSecretKey(); + const operatorSk = generateSecretKey(); + const operatorPubkey = getPublicKey(operatorSk); + + const adminPool = new RelayPool([relay.url], { log: () => {} }); + adminPool.start(); + const admin = new Nip46Transport(adminSk, adminPool); + await admin.start(() => {}, [NOSTR_CONNECT, ADMIN_RESPONSE]); + + const opPool = new RelayPool([relay.url], { log: () => {} }); + opPool.start(); + const operator = new Nip46Transport(operatorSk, opPool); + // The operator auto-approves any "acl" request it receives. + await operator.start((req) => { + if (req.method === "acl") { + void operator.sendResponse(req.id, req.remotePubkey, JSON.stringify(["always"]), req.encryption, undefined, ADMIN_RESPONSE); + } + }, [NOSTR_CONNECT, ADMIN_RESPONSE]); + + await waitFor(() => adminPool.healthy() && opPool.healthy()); + + // Admin asks the operator to approve; expects the routed response. + const approved = await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("approval timed out")), 5000); + const id = admin.sendRequest(operatorPubkey, "acl", ["{}"], "nip44", ADMIN_RESPONSE, (res) => { + admin.clearPending(id); + clearTimeout(t); + resolve(res.result); + }); + }); + + assert.equal(JSON.parse(approved)[0], "always", "operator's approval routed back to the admin"); + + admin.stop(); + operator.stop(); + adminPool.stop(); + opPool.stop(); + await relay.stop(); +});