feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source #17
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-16-autounlock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #16.
Why
Every bunker restart locks all encrypted keys on disk. Each
RemoteBunkerSignerconsumer (lnbits today, webapp NIP-46 + satmachineadmin tomorrow) then needs an out-of-bandunlock_keyadmin RPC against the bunker per key before signing / encrypting / decrypting works. With one operator it's a 30-second nuisance. With N operators it's a recurring O(N) manual step and a guaranteed forgot-to-unlock-operator-7 bug.The architecturally-correct fix is at the bunker — keys are nsecbunkerd's domain, the lock state belongs here, and ANY NIP-46 consumer benefits without per-consumer orchestration code. Path-B from coord log
2026-05-31T13:30Z(lnbits → all, archived).What this PR ships
Three commits on
issue-16-autounlockoffdev@106fa80:Commits
fix(daemon): make unlockKey idempotent—unlockKeypreviously had no short-circuit on re-entry: calling against an already-unlocked key would spawn a SECONDBackendinstance with a duplicate kind-24133 subscription, causing wire-event amplification + response-side races. Latent under the manual-unlock posture but load-bearing for autounlock. Now: short-circuit onactiveKeys[keyName]already set; return true without spawning. Safe across all callers (admin RPC, autounlock loop, future periodic sweeps).feat(daemon): boot-time autounlock of encrypted keys—Daemon.maybeAutounlock()wedged at the tail ofstartKeys(). Reads passphrase from one of two mutually-exclusive env vars, sequential unlock loop with continue-on-error, per-key INFO/WARN/ERROR + one boot-summary line. Includesdocs/AUTOUNLOCK.mdwith the security trade-off spelled out by deployment shape.fix(daemon): autounlock walks config.allKeys, not prisma.key— empirical correction caught during smoke. The PrismaKeytable is only populated by thecreate_account(NIP-05) path;create_new_keystores encrypted blobs directly intonsecbunker.json'skeysmap without a Prisma row. So the canonical "what's encrypted at rest" source isconfig.allKeysfiltered to entries with the{iv, data}shape, not the Prisma table.Configuration surface
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASEdocker compose .envflows.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILEunlock_keyRPC required per key per restart).Var prefix
NSEC_BUNKER_*follows the bunker's existing convention (NSEC_BUNKER_DEBUG_TRANSPORT,NSEC_BUNKER_DISABLE_WATCHDOG) — diverges slightly from issue spec'sNSECBUNKER_*but operator muscle-memory wins over verbatim spec match.Observability
Smoke test (passed)
Wired
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE: ${LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE:-}into~/dev/local/docker/regtest/docker-compose.dev.yml(regtest-side change, separate repo), rebuilt + restarted the bunker container:Greg's key auto-unlocked without admin RPC — the original paper-cut is gone.
bad decrypt: historical keys from previous dev cycles encrypted under older passphrases. Continue-on-error works; the bad rows didn't block the good ones (this is the design-intended behavior perdocs/AUTOUNLOCK.md).✅ nsecBunker ready to serve requests.— no client-side "key locked" race window.The downstream
RemoteBunkerSigner.nip44_encryptround-trip through Greg's auto-unlocked key was implicitly proven by the earlier coord-log2026-05-31T13:50Zend-to-end smoke (kind-30078 publish viasigner.nip44_encrypt+signer.sign_event). Same Backend instance + subscription post-auto-unlock as post-manual-unlock; no behavior delta.Test plan
pnpm installclean;pnpm run build(tsup) greenissue-16-autounlock@030d3ceunlockKey()idempotent (commit 1)RemoteBunkerSigner.nip44_decryptround-trips against an auto-unlocked key (implicit from13:50Zsmoke; explicit test deferred until bitspire fires a fresh inbound event)Out of scope (separate issues if/when needed)
Files touched
src/daemon/run.ts— idempotency guard +maybeAutounlockmethod + wedge instartKeys()docs/AUTOUNLOCK.md— new, ~150 lines: config surface, behavior, security trade by deployment shape, observability, what's not in scopeRegtest compose change (separate repo, NOT in this PR)
For local smoke testing I added one env-var line to
~/dev/local/docker/regtest/docker-compose.dev.yml:Parallels the existing
NSEC_BUNKER_DEBUG_TRANSPORTenv line. Currently sitting asM docker-compose.dev.ymlin the regtest repo — Padreug can commit that separately when ready (or leave as a local dev convenience).🤖 Generated with Claude Code
Adds opt-in autounlock to the daemon's boot sequence. Closes the "O(N) manual unlock_key RPC per bunker restart" paper-cut without breaking the secure-by-default posture: deployments that want every restart to gate crypto capability on a human action keep that property by leaving both env vars unset. Configuration — two mutually exclusive env vars: NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE literal passphrase NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE path (newline-trimmed) Both set → fail loud at boot. Neither set → no-op (default, behavior unchanged from pre-#16). Var names follow the bunker's existing NSEC_BUNKER_* convention (see NSEC_BUNKER_DEBUG_TRANSPORT, NSEC_BUNKER_DISABLE_WATCHDOG); the design issue spec'd NSECBUNKER_* but aligning with the existing prefix matters more for operator muscle-memory than matching the issue text verbatim. Implementation: - `Daemon.maybeAutounlock()` wedged at the tail of `startKeys()`. Inherits the relay-subscription lifecycle (EOSE-awaited per #9) that the existing per-key startKey calls established, so there's no "client sees key locked" race window. - Enumeration via `prisma.key.findMany({ where: { deletedAt: null } })` — Key table is the canonical source of truth for what keys exist on the bunker; respects soft-delete. - Per-key call to the existing `unlockKey(keyName, passphrase)`, which is idempotent post-#16 — encrypted-at-rest keys get unlocked on first call; rows already loaded via the unencrypted-config passes above are no-ops. - Sequential loop with continue-on-error. One bad row (corrupted blob, key encrypted under a historical passphrase, etc.) doesn't block the rest of the fleet. Per-key INFO/WARN/ERROR + one summary line. - File-source error (missing path, permission denied) is fatal at boot — same severity as a misconfig. Observability output: 🔓 autounlock: unlocked <keyName> (success) ⚠️ autounlock: unlockKey returned false for <keyName> (...) (soft fail) ❌ autounlock: <keyName> failed: <message> (throw) 🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms> (summary) Single-passphrase invariant: every `create_new_key(name, passphrase)` in our usage today uses the same passphrase (LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE on the lnbits side), so one autounlock passphrase covers every encrypted key. Per-key passphrase support is a separate feature (out of scope — see #16 "out of scope" section + docs/AUTOUNLOCK.md "What's not in scope"). `docs/AUTOUNLOCK.md` ships alongside: usage, the security trade spelled out by deployment shape, observability hooks, what's deliberately not in scope. Required-reading link before any operator flips the env var on for a production-shaped deployment. Refs aiolabs/nsecbunkerd#16. Builds on idempotent unlockKey from the previous commit on this branch.The first cut of `maybeAutounlock` enumerated `prisma.key` based on the design issue's pseudocode. Empirically that's the wrong source: the Prisma `Key` table is only populated by the NIP-05 `create_account` path, which stores keys *plain-at-rest* in `nsecbunker.json` (no encryption involved). The `create_new_key` flow that lnbits's `RemoteBunkerSigner` uses provisions encrypted `{iv, data}` blobs directly into the JSON `keys` map without touching the Prisma table at all. Result of the v1 enumeration on regtest: 🔓 autounlock: enabled (source=NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE), unlocked 0/0 keys in 0ms …despite 67 encrypted blobs sitting in nsecbunker.json. The Prisma table was empty because none of the regtest keys came from `create_account`. Greg's key would have been a no-op even with the autounlock env set; the manual `unlock_key` admin RPC would still have been required. Fix: enumerate `this.config.allKeys` (the in-memory snapshot of `nsecbunker.json`'s `keys` map, populated at daemon-fork time per `src/commands/start.ts:144`) filtered to entries with the `iv`+`data` shape. That's the canonical "what's encrypted at rest" set — exactly the rows for which manual `unlock_key` was previously required per restart. Plain-key entries (`{key: ...}` from `create_account`) are skipped here for log clarity — they were already loaded by `startKeys`' second pass and live in `activeKeys`; `unlockKey`'s post-#16 idempotency guard would no-op them anyway, but emitting "unlocked" log lines for keys that didn't need unlocking is noise. Updates `docs/AUTOUNLOCK.md` accordingly so the description matches the implementation. Refs aiolabs/nsecbunkerd#16.