startKey passes bech32 nsec to NDKPrivateKeySigner — every newly-created key fails to load #8

Closed
opened 2026-05-25 22:30:43 +00:00 by padreug · 0 comments
Owner

Symptom

After a successful create_new_key admin RPC, the bunker logs:

setting up skeleton profile for alice
Error loading key alice: Error: private key must be 32 bytes, hex or bigint, not string
    at Object.normPrivateKeyToScalar
    at schnorrGetExtPubKey
    at Object.schnorrGetPublicKey [as getPublicKey]
    at getPublicKey (nostr-tools)
    at new _NDKPrivateKeySigner (@nostr-dev-kit/ndk@2.8.1)
    at Daemon.startKey (src/daemon/run.ts:233)
    at Daemon.loadNsec (src/daemon/run.ts:262)
    at createNewKey (src/daemon/admin/commands/create_new_key.ts:35)
    at async AdminInterface.handleRequest

create_new_key returns successfully (the npub is computed before the failing call) and the key is persisted encrypted to disk — but the key is never loaded into the bunker's runtime keystore, so:

  • get_keys reports [] (or throws, per #5)
  • The target key cannot sign anything via NIP-46
  • Any downstream create_new_policy/create_new_token/sign_event flow against the new key silently times out from the client side

This is the gating bug that blocks every signing flow against any key created via the admin RPC interface — i.e. essentially everything the daemon is for. Separate from #5 (which is the getKeys iterator type bug) — this one is in the key loading path, not the listing path.

Root cause

src/daemon/run.ts:startKey (current, unpatched):

async startKey(name: string, nsec: string) {
    const cb = signingAuthorizationCallback(name, this.adminInterface);
    let hexpk: string;

    if (nsec.startsWith('nsec1')) {
        try {
            const key = new NDKPrivateKeySigner(nsec);   // ← bug
            hexpk = key.privateKey!;
        } catch(e) {
            console.error(`Error loading key ${name}:`, e);
            return
        }
    } else {
        hexpk = nsec;
    }
    ...
}

NDK 2.8.1's NDKPrivateKeySigner constructor (verified at node_modules/.pnpm/@nostr-dev-kit+ndk@2.8.1_typescript@5.1.3/node_modules/@nostr-dev-kit/ndk/dist/index.js:5138):

constructor(privateKey) {
  if (privateKey) {
    this.privateKey = privateKey;
    this._user = new NDKUser({
      pubkey: getPublicKey(this.privateKey)  // ← forwards the bech32 string straight to nostr-tools
    });
  }
}

nostr-tools.getPublicKey requires 32-byte hex (or bytes/bigint) and throws on bech32 input. The try/catch in startKey catches the throw and returns silently — the daemon keeps running but the key is now unloaded forever (no retry path).

The callers (create_new_key.ts:26, unlockKey, startKeys at boot) all pass freshly-encoded bech32 nsecs, so EVERY load attempt hits this path.

Fix

Decode the bech32 first:

if (nsec.startsWith('nsec1')) {
    try {
        hexpk = nip19.decode(nsec).data as string;
    } catch(e) {
        console.error(`Error loading key ${name}:`, e);
        return
    }
} else {
    hexpk = nsec;
}

(nip19 is already imported at the top of the file.) Same pattern as create_new_key.ts:16 already uses (new NDKPrivateKeySigner(nip19.decode(_nsec).data as string)) — so the precedent for the decode-before-construct shape exists elsewhere in the codebase.

Applied in our fork at aiolabs/nsecbunkerd@<this PR>. Verified spike: post-patch, create_new_key provisions a target, the key loads cleanly, and get_keys returns the newly-created key (assuming #5 is also fixed, which is needed for the iterator).

Reproduction (post-#1, #2, #3 patches)

  1. Boot a fresh nsecbunker against any relay (public or private — issue is independent of #7).
  2. Send create_new_key('alice', 'passphrase') via the admin RPC interface.
  3. The RPC returns {npub: ...} successfully.
  4. Observe Error loading key alice: ... in the bunker logs.
  5. Send get_keys — returns [] (or throws per #5).
  6. Attempt to NIP-46 connect to the target npub — bunker has no signer for it; the connect attempt times out.

Hypothesis on why this regressed

Likely worked against an older NDK version where NDKPrivateKeySigner decoded bech32 internally, then NDK changed to require hex without anyone updating the bunker. The fact that create_new_key.ts:16 already decodes (for the _nsec parameter path) supports the theory that callers got patched piecemeal but startKey was missed.

Impact

Critical. Without this, the bunker can store keys but cannot use them. Every signing flow against an admin-created target is broken.

For the LNbits integration in aiolabs/lnbits#18, this is unblocking — once patched, create_new_key + NIP-46 signing flows can be tested end-to-end.

Acceptance

  • Local patch applied + spike validated.
  • Upstream cherry-pick / PR.
  • Add regression test: create_new_key + immediate sign_event round-trip should succeed.
  • Audit the other 3 NDKPrivateKeySigner call sites that pass through user-supplied data (backend/index.ts:16, admin/index.ts:56, client.ts:121) for the same bug.

Cross-refs

  • Discovered during the aiolabs/lnbits#18 phase 2 spike — gating the plain-WebSocket signer sidestep path from #7.
  • Related: #5 (getKeys type bug) — same class of error (bech32-vs-hex / object-vs-string confusion) in a different code path. Likely both regressed at the same NDK bump.
  • See ~/dev/lnbits/nsec-bunker-spike-findings.md for the spike harness output that confirmed this.
## Symptom After a successful `create_new_key` admin RPC, the bunker logs: ``` setting up skeleton profile for alice Error loading key alice: Error: private key must be 32 bytes, hex or bigint, not string at Object.normPrivateKeyToScalar at schnorrGetExtPubKey at Object.schnorrGetPublicKey [as getPublicKey] at getPublicKey (nostr-tools) at new _NDKPrivateKeySigner (@nostr-dev-kit/ndk@2.8.1) at Daemon.startKey (src/daemon/run.ts:233) at Daemon.loadNsec (src/daemon/run.ts:262) at createNewKey (src/daemon/admin/commands/create_new_key.ts:35) at async AdminInterface.handleRequest ``` `create_new_key` returns successfully (the npub is computed before the failing call) and the key is persisted encrypted to disk — but the key is **never loaded into the bunker's runtime keystore**, so: - `get_keys` reports `[]` (or throws, per #5) - The target key cannot sign anything via NIP-46 - Any downstream `create_new_policy`/`create_new_token`/`sign_event` flow against the new key silently times out from the client side This is the gating bug that blocks every signing flow against any key created via the admin RPC interface — i.e. essentially everything the daemon is for. Separate from #5 (which is the `getKeys` iterator type bug) — this one is in the key *loading* path, not the listing path. ## Root cause `src/daemon/run.ts:startKey` (current, unpatched): ```ts async startKey(name: string, nsec: string) { const cb = signingAuthorizationCallback(name, this.adminInterface); let hexpk: string; if (nsec.startsWith('nsec1')) { try { const key = new NDKPrivateKeySigner(nsec); // ← bug hexpk = key.privateKey!; } catch(e) { console.error(`Error loading key ${name}:`, e); return } } else { hexpk = nsec; } ... } ``` NDK 2.8.1's `NDKPrivateKeySigner` constructor (verified at `node_modules/.pnpm/@nostr-dev-kit+ndk@2.8.1_typescript@5.1.3/node_modules/@nostr-dev-kit/ndk/dist/index.js:5138`): ```js constructor(privateKey) { if (privateKey) { this.privateKey = privateKey; this._user = new NDKUser({ pubkey: getPublicKey(this.privateKey) // ← forwards the bech32 string straight to nostr-tools }); } } ``` `nostr-tools.getPublicKey` requires 32-byte hex (or bytes/bigint) and throws on bech32 input. The `try/catch` in `startKey` catches the throw and returns silently — the daemon keeps running but the key is now unloaded forever (no retry path). The callers (`create_new_key.ts:26`, `unlockKey`, `startKeys` at boot) all pass freshly-encoded bech32 nsecs, so EVERY load attempt hits this path. ## Fix Decode the bech32 first: ```ts if (nsec.startsWith('nsec1')) { try { hexpk = nip19.decode(nsec).data as string; } catch(e) { console.error(`Error loading key ${name}:`, e); return } } else { hexpk = nsec; } ``` (`nip19` is already imported at the top of the file.) Same pattern as `create_new_key.ts:16` already uses (`new NDKPrivateKeySigner(nip19.decode(_nsec).data as string)`) — so the precedent for the decode-before-construct shape exists elsewhere in the codebase. Applied in our fork at `aiolabs/nsecbunkerd@<this PR>`. Verified spike: post-patch, `create_new_key` provisions a target, the key loads cleanly, and `get_keys` returns the newly-created key (assuming #5 is also fixed, which is needed for the iterator). ## Reproduction (post-#1, #2, #3 patches) 1. Boot a fresh nsecbunker against any relay (public or private — issue is independent of #7). 2. Send `create_new_key('alice', 'passphrase')` via the admin RPC interface. 3. The RPC returns `{npub: ...}` successfully. 4. Observe `Error loading key alice: ...` in the bunker logs. 5. Send `get_keys` — returns `[]` (or throws per #5). 6. Attempt to NIP-46 connect to the target npub — bunker has no signer for it; the connect attempt times out. ## Hypothesis on why this regressed Likely worked against an older NDK version where `NDKPrivateKeySigner` decoded bech32 internally, then NDK changed to require hex without anyone updating the bunker. The fact that `create_new_key.ts:16` already decodes (for the `_nsec` parameter path) supports the theory that callers got patched piecemeal but `startKey` was missed. ## Impact **Critical.** Without this, the bunker can store keys but cannot use them. Every signing flow against an admin-created target is broken. For the LNbits integration in `aiolabs/lnbits#18`, this is unblocking — once patched, `create_new_key` + NIP-46 signing flows can be tested end-to-end. ## Acceptance - [x] Local patch applied + spike validated. - [ ] Upstream cherry-pick / PR. - [ ] Add regression test: create_new_key + immediate sign_event round-trip should succeed. - [ ] Audit the other 3 NDKPrivateKeySigner call sites that pass through user-supplied data (`backend/index.ts:16`, `admin/index.ts:56`, `client.ts:121`) for the same bug. ## Cross-refs - Discovered during the `aiolabs/lnbits#18` phase 2 spike — gating the plain-WebSocket signer sidestep path from #7. - Related: #5 (`getKeys` type bug) — same class of error (bech32-vs-hex / object-vs-string confusion) in a different code path. Likely both regressed at the same NDK bump. - See `~/dev/lnbits/nsec-bunker-spike-findings.md` for the spike harness output that confirmed this.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd#8
No description provided.