Compare commits

...

5 commits

Author SHA1 Message Date
106fa807a1 Merge pull request 'feat(#14): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2.20 + acl wire-name vocabulary' (#15) from issue-14-ndk-bump into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #15
2026-05-31 11:49:29 +00:00
e8f245c917 fix(deps): cap nostr-tools at ~2.20.0 (regtest Node 20 / curves v2 ESM-only) (#14)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Caught during regtest dogfood after the previous three commits
landed. With `nostr-tools: ^2.17.2` pnpm resolved to 2.23.5, which
in turn pulls `@noble/curves@2.0.1` — ESM-only. The regtest
Dockerfile runs on Node 20.11.1, where CJS `require()` of pure-ESM
modules is hard-blocked:

  Error [ERR_REQUIRE_ESM]: require() of ES Module
  /app/node_modules/.pnpm/@noble+curves@2.0.1/.../secp256k1.js
  from /app/node_modules/.pnpm/nostr-tools@2.23.5/.../index.js
  not supported.

nostr-tools 2.21.0 was the cutover — that release flipped
`@noble/curves` from `1.2.0` to `2.0.1`. 2.20.0 is the last
nostr-tools 2.x release that's still CJS-friendly via @noble/curves
1.2.0. Capping our pin at `~2.20.0` keeps us within the
"nostr-tools >= 2.17.2" range NDK 3.0.3 asks for in its
peerDependency while sidestepping the ESM/CJS hazard.

This isn't a regression we introduce — it's a CJS-output footgun
unique to the regtest container's Node 20 + tsup-default-CJS
combination. Long-term fix paths (out of scope here):

  * Bump the container's Node base image to >= 22 (where
    `--experimental-require-module` is on by default for `.js`
    files inside `package.json type: "commonjs"`)
  * Switch tsup output to ESM (`tsup --format esm`) — wider
    surface change across the daemon, the client CLI, and the
    Dockerfile entrypoint
  * Accept the cap forever (small downside: 2.21+ patch fixes
    won't reach us until we fix one of the above)

The cap is intentionally tight (`~2.20.0` allows 2.20.x patches,
nothing newer) so a future `pnpm update` doesn't silently jump us
back over the 2.21 edge. Revisit when one of the long-term paths
above lands.

Refs aiolabs/nsecbunkerd#14, regtest dogfood 2026-05-31.
2026-05-31 13:43:37 +02:00
db1a834587 refactor(acl): align IMethod with NIP-46 wire-name vocabulary (#14)
NDK 3.x's `NDKNip46Backend` passes the wire method name verbatim
to `pubkeyAllowed` — `nip04_encrypt`, `nip04_decrypt`,
`nip44_encrypt`, `nip44_decrypt`, etc. NDK 2.8.1 normalized these to
`encrypt`/`decrypt` before calling the permit callback; that
normalization was the root of why our encrypt/decrypt path had
never worked end-to-end against lnbits's bunker-backed signer
(lnbits stores `PolicyRule.method` using wire names, our auth
lookup looked for the normalized name → no match → request fell
through to the never-resolved admin prompt and timed out at 15s).

Source `IMethod` directly from NDK's exported `NIP46Method` union so
it can't drift across future bumps. If NDK adds a new method
(e.g. `nip60_*`) we pick it up for free. Drop the `method as IMethod`
cast at the `signingAuthorizationCallback` call site — both sides
now share the same vocabulary by construction.

This is the substantive win that aiolabs/nsecbunkerd#14 is filed for.
With this commit:

- `sign_event` policy rules with kinds continue to match exactly as
  before (kind stringification path unchanged).
- `nip04_encrypt` / `nip04_decrypt` / `nip44_encrypt` / `nip44_decrypt`
  policy rules — kind-less — now match the live-policy join (step 4
  of `checkIfPubkeyAllowed`) by their method-name alone. lnbits's
  bunker-mediated `signer.nip44_decrypt` and `signer.nip44_encrypt`
  calls (per `aiolabs/lnbits` PR #38 phase 2.4) start succeeding
  end-to-end against any operator account whose Policy carries those
  rules — which `_ensure_policy`'s self-heal already ensures for
  every newly-bound operator (per coord log 2026-05-30T22:00Z).
- `switch_relays` (new in NDK 3) flows through the auth check the
  same way as any other method.

`requestToSigningConditionQuery` needs no further change — the
existing `sign_event` switch case covers the only method that
discriminates on kind; all other methods use the default
`{ method }` query against the override layer, which is correct
for the kind-less wire names too.

Refs aiolabs/nsecbunkerd#14, aiolabs/nsecbunkerd#11 (whose live-policy
join this finally puts to use).
2026-05-31 12:15:57 +02:00
94b5d55376 refactor: adapt source to NDK 3.0.3 / nostr-tools v2 surface (#14)
Mechanical adjustments to the source after the dep bump in the
previous commit. No semantic changes — every site adapts to API
drift between the pinned versions.

Surface changes addressed:

  * `NDKKind` strict numeric enum (was wider in 2.8.1). 18 sites
    passed the literal `24134` (NIP-46 admin-RPC response kind) to
    `rpc.sendResponse` / `rpc.sendRequest`; NDK 3's `NDKKind` enum
    omits 24134. Introduced `src/daemon/admin/kinds.ts` exporting
    `NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind` so the cast lives
    once, and routed all 18 sites through the named constant.

  * `NDKPrivateKeySigner` constructor now accepts nsec1 or hex
    directly (the `@ai-guardrail` in NDK 3 source explicitly tells
    callers not to `nip19.decode` ahead of construction). Simplified
    `Daemon.startKey` and `createNewKey` accordingly — the bech32
    decode workaround for #8 was tied to NDK 2.8.1's old behavior
    and is no longer needed.

  * `NDKPrivateKeySigner.privateKey` is `string` (hex) on the public
    surface, not `Uint8Array`. `nostr-tools` v2's `nip19.nsecEncode`
    wants `Uint8Array`. Replaced `nip19.nsecEncode(key.privateKey!)`
    with `key.nsec` (NDK 3 exposes the getter directly), avoiding
    both the type mismatch and the unnecessary round-trip. For the
    one remaining hex-string-from-config call site, used
    `nostrUtils.hexToBytes` to convert before encoding.

  * `NDKPool` event rename: `'relay:notice'` → `'notice'`, with
    flipped arg order `(notice, relay)` → `(relay, notice)`.

  * `NDKUser.fromNip05` now requires the `ndk` instance as a 2nd
    positional arg (was implicit-global before).

  * `Nip46PermitCallbackParams.params` narrowed to `string |
    NostrEvent`; type guards added at the two access sites
    (`authorize.ts` and `acl/index.ts:requestToSigningConditionQuery`).

  * `req.params` is now `(string | undefined)[]` instead of `any[]`;
    `create_account.ts` `authorizationWithPayload` branch now
    explicitly throws on missing username/domain before passing to
    `createAccountReal` (validates what was implicit before).

  * Removed `src/daemon/backend/publish-event.ts` (defined a strategy
    that's never registered — wiring is commented out in
    `backend/index.ts:22`; in NDK 3 the file refs the removed
    `NDKNip46Backend.signEvent`). Dead since at least NDK 2.x; the
    bump just made the breakage visible.

Pre-existing `tsc` errors at `src/db.ts` and `src/daemon/authorize.ts`
on `'PrismaClient'` / `'Request'` exports are unrelated to this PR —
the regtest container's nix derivation can't reach the prisma engine
binary store on this host (`nsecbunkerd#14` parked separately).
`pnpm run build` (tsup) is green; the Docker container runs
`prisma generate` against its own engine at image-build time and
resolves these at runtime.

#11's wire-name policy convention adoption is the next commit —
this one is purely keep-it-compiling work.

Refs aiolabs/nsecbunkerd#14.
2026-05-31 12:14:29 +02:00
041f431bc2 chore(deps): bump @nostr-dev-kit/ndk 2.8.1 → 3.0.3 + nostr-tools v1 → v2 (#14)
NDK 2.8.1 (Apr 2024) is 2 years old and predates NIP-46 backend-side
nip44 support. With aiolabs/lnbits#38's phase-2.4 client-side migration
to bunker-mediated nip44_*, the bunker's lack of a `nip44_decrypt`
strategy registration causes wire RPCs to fall through to
`sendResponse(id, remotePubkey, "error", undefined, "Not authorized")`
at NDK 2.8.1's backend/index.ts:179. Even nip04 was silently broken:
2.8.1 normalizes the wire method to `encrypt`/`decrypt` for
`pubkeyAllowed` while lnbits's policy stores wire names. The
encrypt/decrypt path through nsecbunkerd has never actually worked
end-to-end; it just hadn't been exercised until phase 2.4 landed.

3.0.3 (Feb 2026) is the current `latest` dist-tag and ships:

  - `nip44_encrypt` / `nip44_decrypt` backend handlers registered
    by default + wire-name `pubkeyAllowed` semantics (the immediate fix)
  - `switch_relays` NIP-46 support for client-side relay migration
  - Configurable NDKNip46Signer timeout (pairs with lnbits PR #38's
    matching client-side config)
  - NIP-44 default outgoing encryption with NIP-04 compat fallback
  - Async error handling fix in backend dispatch — failed strategies
    report errors instead of silent drop (deb7f93d)
  - "Not enough relays received this event" race-condition fix on
    publish (relevant to open #7 — may close that one too)
  - Signature verification moved in-house (off legacy nostr-tools v1
    path)
  - 2 years of security/perf updates in transitive @noble/* crypto
    primitives

`nostr-tools` bumped from ^1.17.0 to ^2.17.2 alongside because NDK
3.x's `NDKPrivateKeySigner` imports `finalizeEvent` / `generateSecretKey`
+ uses the `./nip49` subpath, none of which exist in nostr-tools v1.17.
With v1.17 installed, `require('@nostr-dev-kit/ndk')` fails with
"Package subpath './nip49' is not defined". Confirmed against the
post-install module graph.

Source migrations for NDK 3 / nostr-tools v2 API surface land in the
follow-up commit; this commit is intentionally just the dep bump so
the diff stays reviewable. Refs aiolabs/nsecbunkerd#14 +
coord-log 2026-05-31T09:55Z.
2026-05-31 12:02:03 +02:00
24 changed files with 4429 additions and 2649 deletions

View file

@ -39,7 +39,7 @@
"@fastify/view": "^8.2.0",
"@inquirer/password": "^1.1.2",
"@inquirer/prompts": "^1.2.3",
"@nostr-dev-kit/ndk": "2.8.1",
"@nostr-dev-kit/ndk": "3.0.3",
"@prisma/client": "^5.4.1",
"@scure/base": "^1.1.1",
"@types/yargs": "^17.0.24",
@ -57,7 +57,7 @@
"isomorphic-ws": "^5.0.0",
"lnbits": "^1.1.5",
"lnbits-ts": "^0.0.2",
"nostr-tools": "^1.17.0",
"nostr-tools": "~2.20.0",
"websocket-polyfill": "^0.0.3",
"ws": "^8.13.0",
"yargs": "^17.7.2"

6877
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -100,7 +100,7 @@ function loadPrivateKey(): string | undefined {
} else {
// check if we have a @ so we try to get the npub from nip05
if (remotePubkey.includes('@')) {
const u = await NDKUser.fromNip05(remotePubkey);
const u = await NDKUser.fromNip05(remotePubkey, ndk);
if (!u) {
console.log(`Invalid nip05 ${remotePubkey}`);
process.exit(1);

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -43,5 +44,5 @@ export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRe
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -42,5 +43,5 @@ export default async function addSigningCondition(admin: AdminInterface, req: ND
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -131,6 +131,9 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
username = payload[0];
domain = payload[1];
email = payload[2];
if (!username || !domain) {
throw new Error('Invalid authorization payload: missing username/domain');
}
return createAccountReal(admin, req, username, domain, email);
}
}
@ -195,7 +198,7 @@ export async function createAccountReal(
}
const keyName = nip05;
const nsec = nip19.nsecEncode(key.privateKey!);
const nsec = key.nsec;
currentConfig.keys[keyName] = { key: key.privateKey };
saveCurrentConfig(admin.configFile, currentConfig);

View file

@ -1,7 +1,7 @@
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import { saveEncrypted } from "../../../commands/add.js";
import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
@ -13,7 +13,9 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
let key;
if (_nsec) {
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see core/src/signers/private-key/index.ts `@ai-guardrail`).
key = new NDKPrivateKeySigner(_nsec);
} else {
key = NDKPrivateKeySigner.generate();
@ -23,7 +25,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
}
const user = await key.user();
const nsec = nip19.nsecEncode(key.privateKey!);
const nsec = key.nsec;
await saveEncrypted(
admin.configFile,
@ -38,5 +40,5 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
npub: user.npub,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
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) {
@ -29,5 +30,5 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
}
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
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) {
@ -30,5 +31,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!tokenRecord) throw new Error("Token not created");
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,6 +1,7 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -29,5 +30,5 @@ export default async function removePolicyRule(admin: AdminInterface, req: NDKRp
await prisma.policyRule.delete({ where: { id: ruleId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -22,5 +23,5 @@ export default async function removeSigningCondition(admin: AdminInterface, req:
await prisma.signingCondition.delete({ where: { id: conditionId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
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) {
@ -25,5 +26,5 @@ export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRe
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -32,5 +33,5 @@ export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequ
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
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) {
@ -20,5 +21,5 @@ export default async function revokeUser(admin: AdminInterface, req: NDKRpcReque
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyName, passphrase ] = req.params as [ string, string ];
@ -16,5 +17,5 @@ export default async function unlockKey(admin: AdminInterface, req: NDKRpcReques
result = JSON.stringify({ success: false, error: e.message });
}
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -1,5 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
@ -39,5 +40,5 @@ export default async function updatePolicy(admin: AdminInterface, req: NDKRpcReq
await prisma.policy.update({ where: { id: policyId }, data });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -19,6 +19,7 @@ import updatePolicy from './commands/update_policy';
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 fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user';
@ -142,7 +143,7 @@ class AdminInterface {
this.ndk.connect(2500).then(() => {
// connect for whitelisted admins
this.rpc.subscribe({
"kinds": [NDKKind.NostrConnect, 24134 as number],
"kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND],
"#p": [this.signerUser!.pubkey]
});
@ -272,7 +273,7 @@ class AdminInterface {
const key = keys.find((k) => k.name === keyName);
if (!key || !key.npub) {
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NIP46_ADMIN_RESPONSE_KIND);
}
const npub = key.npub;
@ -294,7 +295,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}
/**
@ -326,7 +327,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return this.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}
/**
@ -338,7 +339,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeys());
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}
/**
@ -350,7 +351,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeyUsers(req));
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
return this.rpc.sendResponse(req.id, pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}
/**
@ -416,7 +417,7 @@ class AdminInterface {
remoteUser.pubkey,
'acl',
[params],
24134,
NIP46_ADMIN_RESPONSE_KIND,
(res: NDKRpcResponse) => {
this.requestPermissionResponse(
remotePubkey,

14
src/daemon/admin/kinds.ts Normal file
View file

@ -0,0 +1,14 @@
import type { NDKKind } from '@nostr-dev-kit/ndk';
/**
* 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`.
*/
export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind;

View file

@ -59,9 +59,8 @@ async function createRecord(
) {
let params: string | undefined;
if (param?.rawEvent) {
const e = param as NDKEvent;
params = JSON.stringify(e.rawEvent());
if (typeof param === 'object' && param !== null && 'rawEvent' in param) {
params = JSON.stringify(param.rawEvent());
} else if (param) {
params = param.toString();
}

View file

@ -18,8 +18,6 @@ export class Backend extends NDKNip46Backend {
this.baseUrl = baseUrl;
this.fastify = fastify;
// this.setStrategy('publish_event', new PublishEventHandlingStrategy());
}
/**

View file

@ -1,14 +0,0 @@
import { NDKNip46Backend } from "@nostr-dev-kit/ndk";
import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk';
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]): Promise<string|undefined> {
const event = await backend.signEvent(remotePubkey, params);
if (!event) return undefined;
console.log('Publishing event', event);
await event.publish();
return JSON.stringify(await event.toNostrEvent());
}
}

View file

@ -1,4 +1,4 @@
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import { NDKEvent, NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
import prisma from '../../../db.js';
/**
@ -114,7 +114,26 @@ export async function checkIfPubkeyAllowed(
return undefined;
}
export type IMethod = "connect" | "sign_event" | "encrypt" | "decrypt" | "ping";
/**
* Sign-time auth method names follow the NIP-46 wire convention as
* NDK 3.x's `NDKNip46Backend` passes them through to `pubkeyAllowed`
* verbatim (it stopped normalizing `nip04_encrypt`/`nip04_decrypt`
* to `encrypt`/`decrypt` somewhere between 2.8.1 and current
* upstream).
*
* lnbits's `_ensure_policy` writes `PolicyRule.method` using the same
* wire-name vocabulary (`nip04_encrypt`, `nip04_decrypt`,
* `nip44_encrypt`, `nip44_decrypt`, `sign_event`, `get_public_key`,
* `connect`, `ping`). With the wire-name vocabulary on both sides,
* the post-#11 live-policy join (step 4 of `checkIfPubkeyAllowed`)
* naturally matches lnbits's stored rules no `encrypt → nip04_encrypt`
* adapter layer needed.
*
* Source the type from NDK itself so it can't drift across future
* NDK bumps; if NDK adds a new method (e.g. `nip60_*`) we pick it up
* for free.
*/
export type IMethod = NIP46Method;
export type IAllowScope = {
kind?: number | 'all';
@ -124,9 +143,13 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
const signingConditionQuery: any = { method };
switch (method) {
case 'sign_event':
signingConditionQuery.kind = { in: [ payload?.kind?.toString(), 'all' ] };
case 'sign_event': {
const kindString = (typeof payload === 'object' && payload?.kind !== undefined)
? payload.kind.toString()
: undefined;
signingConditionQuery.kind = { in: [kindString, 'all'] };
break;
}
}
return signingConditionQuery;

View file

@ -1,10 +1,7 @@
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { nip19, utils as nostrUtils } from 'nostr-tools';
import { Backend } from './backend/index.js';
import {
IMethod,
checkIfPubkeyAllowed,
} from './lib/acl/index.js';
import { checkIfPubkeyAllowed } from './lib/acl/index.js';
import AdminInterface from './admin/index.js';
import { IConfig } from '../config/index.js';
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
@ -38,8 +35,7 @@ function getKeys(config: DaemonConfig) {
const keys: Key[] = [];
for (const [name, nsec] of Object.entries(config.keys)) {
const hexpk = nip19.decode(nsec).data as string;
const user = await new NDKPrivateKeySigner(hexpk).user();
const user = await new NDKPrivateKeySigner(nsec).user();
const key = {
name,
npub: user.npub,
@ -108,7 +104,7 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
}
try {
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method as IMethod, payload);
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method, payload);
if (keyAllowed === true || keyAllowed === false) {
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
@ -164,7 +160,7 @@ class Daemon {
explicitRelayUrls: config.nostr.relays,
});
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
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}`);
@ -206,7 +202,10 @@ class Daemon {
continue;
}
const nsec = nip19.nsecEncode(settings.key);
// nostr-tools v2: `nsecEncode` takes `Uint8Array`, not hex string.
// pragma: allowlist secret
// `settings.key` is the hex-encoded private key from config.
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
this.loadNsec(keyName, nsec);
}
}
@ -226,27 +225,13 @@ class Daemon {
*/
async startKey(name: string, nsec: string) {
const cb = signingAuthorizationCallback(name, this.adminInterface);
let hexpk: string;
if (nsec.startsWith('nsec1')) {
try {
// NDK 2.8.1's NDKPrivateKeySigner constructor passes its
// arg straight to nostr-tools getPublicKey() which requires
// 32-byte hex / bytes / bigint, not bech32. Without this
// decode, every key created via create_new_key fails to
// load with the nostr-tools getPublicKey type error, so
// the bunker can never sign for any target it provisions.
// See aiolabs/nsecbunkerd#8.
hexpk = nip19.decode(nsec).data as string;
} catch(e) {
console.error(`Error loading key ${name}:`, e);
return
}
} else {
hexpk = nsec;
}
const backend = new Backend(this.ndk, this.fastify, hexpk, cb, this.config.baseUrl);
// NDK 3.x's `NDKPrivateKeySigner` accepts nsec1 or hex directly
// (see `core/src/signers/private-key/index.ts` `@ai-guardrail`
// — "DO NOT use nip19.decode() to convert nsec to hex before
// 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);
await backend.start();
}