feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source #17

Merged
padreug merged 3 commits from issue-16-autounlock into dev 2026-05-31 13:39:18 +00:00
Showing only changes of commit b6f8abdb23 - Show all commits

fix(daemon): make unlockKey idempotent (#16)

`unlockKey(keyName, passphrase)` previously had no short-circuit on
re-entry — calling it against an already-unlocked key would happily
run through the full path:

  1. decryptNsec (cheap, same result)
  2. overwrite this.activeKeys[keyName] with the same nsec
  3. call startKey(keyName, nsec) → spawn a SECOND Backend instance

Step 3 is the actual hazard. Each Backend opens its own NIP-46 kind-
24133 subscription with the relay, scoped to the key's pubkey. Two
Backends → duplicate subscription → wire events delivered twice and
each handler races to publish its response. Response amplification +
ordering hazards downstream, plus a slow leak of NDK subscription
state every time unlock fires.

This bug was latent under the manual-unlock posture today (admins
rarely re-issue unlock_key for the same name in one session) but
becomes load-bearing for #16's autounlock loop, which is designed
to run alongside the existing startKeys() loops and may legitimately
encounter a key that was already loaded via the unencrypted-config
path. Belt-and-suspenders lnbits-side scripts + future periodic
"re-unlock sweeps for paranoia" can also fire this.

Fix: short-circuit on `this.activeKeys[keyName]` already set. Return
true so callers can rely on "after this call returns, the key is
unlocked and ready" regardless of whether work was done. Doesn't
break the manual flow (still unlocks first-time), doesn't change
the failure path (corrupt blob / wrong passphrase still throws),
just closes the re-entry foot-gun.

Refs aiolabs/nsecbunkerd#16 (autounlock — this is the idempotency
sub-task lnbits flagged in the design surface).
Padreug 2026-05-31 15:29:07 +02:00

View file

@ -236,6 +236,19 @@ class Daemon {
}
async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
// Idempotency guard: if a Backend instance already exists for this
// keyName, the key is already unlocked and the relay subscription
// for its kind-24133 channel is already active. Calling startKey
// again would spawn a SECOND Backend with a duplicate subscription
// — wire events would be handled twice, with race/amplification
// hazards on the response side. Return success without re-running
// startKey so callers (admin `unlock_key` RPC, autounlock loop,
// belt-and-suspenders fallback paths) can fire safely against
// already-unlocked keys.
if (this.activeKeys[keyName]) {
return true;
}
const keyData = this.config.allKeys[keyName];
const { iv, data } = keyData;