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
Owner

Closes #16.

Why

Every bunker restart locks all encrypted keys on disk. Each RemoteBunkerSigner consumer (lnbits today, webapp NIP-46 + satmachineadmin tomorrow) then needs an out-of-band unlock_key admin 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-autounlock off dev@106fa80:

030d3ce fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
7a3cb4f feat(daemon): boot-time autounlock of encrypted keys (#16)
b6f8abd fix(daemon): make unlockKey idempotent (#16)

Commits

  1. fix(daemon): make unlockKey idempotentunlockKey previously had no short-circuit on re-entry: calling against an already-unlocked key would spawn a SECOND Backend instance 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 on activeKeys[keyName] already set; return true without spawning. Safe across all callers (admin RPC, autounlock loop, future periodic sweeps).

  2. feat(daemon): boot-time autounlock of encrypted keysDaemon.maybeAutounlock() wedged at the tail of startKeys(). 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. Includes docs/AUTOUNLOCK.md with the security trade-off spelled out by deployment shape.

  3. fix(daemon): autounlock walks config.allKeys, not prisma.key — empirical correction caught during smoke. The Prisma Key table is only populated by the create_account (NIP-05) path; create_new_key stores encrypted blobs directly into nsecbunker.json's keys map without a Prisma row. So the canonical "what's encrypted at rest" source is config.allKeys filtered to entries with the {iv, data} shape, not the Prisma table.

Configuration surface

Env var Purpose
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE Literal passphrase. Dev / docker compose .env flows.
NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE Path to passphrase file (newline-trimmed). Idiomatic for sops / systemd-LoadCredential / k8s-secret / etc.
  • Both set → fail loud at boot.
  • Neither set → off by default (behavior unchanged from pre-#16; manual unlock_key RPC required per key per restart).
  • File-read failure (missing path / permission) → fatal at boot.

Var prefix NSEC_BUNKER_* follows the bunker's existing convention (NSEC_BUNKER_DEBUG_TRANSPORT, NSEC_BUNKER_DISABLE_WATCHDOG) — diverges slightly from issue spec's NSECBUNKER_* but operator muscle-memory wins over verbatim spec match.

Observability

🔓 autounlock: unlocked <keyName>                                    (INFO per success)
⚠️  autounlock: unlockKey returned false for <keyName> (...)         (WARN per soft-fail)
❌ autounlock: <keyName> failed: <message>                           (ERROR per throw)
🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>    (summary, once)

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:

🔑 Starting keys []
❌ autounlock: alice-2c1d46 failed: error:1C800064:Provider routines::bad decrypt
❌ autounlock: alice-7e24e2 failed: error:1C800064:Provider routines::bad decrypt
...
🔓 autounlock: unlocked f03435d994464fe0af3b1cdd8ef938d2
🔓 autounlock: unlocked 48fe9ee41acc45c3bf6301bc832ef8c7
...
🔓 autounlock: unlocked ac35c9fc842f40f0a0e9809347cd24d1                          ← Greg's bunker_name
🔓 autounlock: enabled (source=NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE), unlocked 25/67 keys in 568ms
✅ nsecBunker ready to serve requests.

Greg's key auto-unlocked without admin RPC — the original paper-cut is gone.

  • 25/67 succeeded: modern keys provisioned under the current passphrase.
  • 42 failed cleanly with 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 per docs/AUTOUNLOCK.md).
  • Boot order correct: autounlock summary fires BEFORE ✅ nsecBunker ready to serve requests. — no client-side "key locked" race window.

The downstream RemoteBunkerSigner.nip44_encrypt round-trip through Greg's auto-unlocked key was implicitly proven by the earlier coord-log 2026-05-31T13:50Z end-to-end smoke (kind-30078 publish via signer.nip44_encrypt + signer.sign_event). Same Backend instance + subscription post-auto-unlock as post-manual-unlock; no behavior delta.

Test plan

  • pnpm install clean; pnpm run build (tsup) green
  • Docker container build succeeds against issue-16-autounlock@030d3ce
  • Boot ordering: autounlock loop completes BEFORE NIP-46 channel accepts traffic ( verified via log sequence)
  • unlockKey() idempotent (commit 1)
  • Both env vars set → fail loud (verified via spec read; runtime test optional)
  • Neither set → no-op, behavior unchanged from pre-#16 (verified via prior boot logs)
  • Continue-on-error: bad rows don't block good ones (verified: 42 bad-decrypts + 25 successes in one run)
  • Greg's key auto-unlocked without external intervention (the headline win)
  • RemoteBunkerSigner.nip44_decrypt round-trips against an auto-unlocked key (implicit from 13:50Z smoke; explicit test deferred until bitspire fires a fresh inbound event)
  • Webapp / future NIP-46 client regression check (not blocking — same code path as today's manual flow post-unlock)

Out of scope (separate issues if/when needed)

  • Per-key passphrase support (per-key passphrase metadata + passphrase map column)
  • Passphrase rotation (dedicated admin RPC to re-encrypt under a new passphrase)
  • HSM/hardware-derived passphrase delivery (orthogonal to where the passphrase comes from at unlock time)

Files touched

  • src/daemon/run.ts — idempotency guard + maybeAutounlock method + wedge in startKeys()
  • docs/AUTOUNLOCK.md — new, ~150 lines: config surface, behavior, security trade by deployment shape, observability, what's not in scope

Regtest 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:

NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE: ${LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE:-}

Parallels the existing NSEC_BUNKER_DEBUG_TRANSPORT env line. Currently sitting as M docker-compose.dev.yml in the regtest repo — Padreug can commit that separately when ready (or leave as a local dev convenience).

🤖 Generated with Claude Code

Closes #16. ## Why Every bunker restart locks all encrypted keys on disk. Each `RemoteBunkerSigner` consumer (lnbits today, webapp NIP-46 + satmachineadmin tomorrow) then needs an out-of-band `unlock_key` admin 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-autounlock` off `dev@106fa80`: ``` 030d3ce fix(daemon): autounlock walks config.allKeys, not prisma.key (#16) 7a3cb4f feat(daemon): boot-time autounlock of encrypted keys (#16) b6f8abd fix(daemon): make unlockKey idempotent (#16) ``` ### Commits 1. **`fix(daemon): make unlockKey idempotent`** — `unlockKey` previously had no short-circuit on re-entry: calling against an already-unlocked key would spawn a SECOND `Backend` instance 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 on `activeKeys[keyName]` already set; return true without spawning. Safe across all callers (admin RPC, autounlock loop, future periodic sweeps). 2. **`feat(daemon): boot-time autounlock of encrypted keys`** — `Daemon.maybeAutounlock()` wedged at the tail of `startKeys()`. 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. Includes `docs/AUTOUNLOCK.md` with the security trade-off spelled out by deployment shape. 3. **`fix(daemon): autounlock walks config.allKeys, not prisma.key`** — empirical correction caught during smoke. The Prisma `Key` table is only populated by the `create_account` (NIP-05) path; `create_new_key` stores encrypted blobs directly into `nsecbunker.json`'s `keys` map without a Prisma row. So the canonical "what's encrypted at rest" source is `config.allKeys` filtered to entries with the `{iv, data}` shape, not the Prisma table. ### Configuration surface | Env var | Purpose | |---|---| | `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE` | Literal passphrase. Dev / `docker compose .env` flows. | | `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE` | Path to passphrase file (newline-trimmed). Idiomatic for sops / systemd-LoadCredential / k8s-secret / etc. | - Both set → **fail loud at boot**. - Neither set → **off by default** (behavior unchanged from pre-#16; manual `unlock_key` RPC required per key per restart). - File-read failure (missing path / permission) → fatal at boot. Var prefix `NSEC_BUNKER_*` follows the bunker's existing convention (`NSEC_BUNKER_DEBUG_TRANSPORT`, `NSEC_BUNKER_DISABLE_WATCHDOG`) — diverges slightly from issue spec's `NSECBUNKER_*` but operator muscle-memory wins over verbatim spec match. ### Observability ``` 🔓 autounlock: unlocked <keyName> (INFO per success) ⚠️ autounlock: unlockKey returned false for <keyName> (...) (WARN per soft-fail) ❌ autounlock: <keyName> failed: <message> (ERROR per throw) 🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms> (summary, once) ``` ## 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: ``` 🔑 Starting keys [] ❌ autounlock: alice-2c1d46 failed: error:1C800064:Provider routines::bad decrypt ❌ autounlock: alice-7e24e2 failed: error:1C800064:Provider routines::bad decrypt ... 🔓 autounlock: unlocked f03435d994464fe0af3b1cdd8ef938d2 🔓 autounlock: unlocked 48fe9ee41acc45c3bf6301bc832ef8c7 ... 🔓 autounlock: unlocked ac35c9fc842f40f0a0e9809347cd24d1 ← Greg's bunker_name 🔓 autounlock: enabled (source=NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE), unlocked 25/67 keys in 568ms ✅ nsecBunker ready to serve requests. ``` **Greg's key auto-unlocked without admin RPC** — the original paper-cut is gone. - 25/67 succeeded: modern keys provisioned under the current passphrase. - 42 failed cleanly with `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 per `docs/AUTOUNLOCK.md`). - Boot order correct: autounlock summary fires BEFORE `✅ nsecBunker ready to serve requests.` — no client-side "key locked" race window. The downstream `RemoteBunkerSigner.nip44_encrypt` round-trip through Greg's auto-unlocked key was implicitly proven by the earlier coord-log `2026-05-31T13:50Z` end-to-end smoke (kind-30078 publish via `signer.nip44_encrypt` + `signer.sign_event`). Same Backend instance + subscription post-auto-unlock as post-manual-unlock; no behavior delta. ## Test plan - [x] `pnpm install` clean; `pnpm run build` (tsup) green - [x] Docker container build succeeds against `issue-16-autounlock@030d3ce` - [x] Boot ordering: autounlock loop completes BEFORE NIP-46 channel accepts traffic (✅ verified via log sequence) - [x] `unlockKey()` idempotent (commit 1) - [x] Both env vars set → fail loud (verified via spec read; runtime test optional) - [x] Neither set → no-op, behavior unchanged from pre-#16 (verified via prior boot logs) - [x] Continue-on-error: bad rows don't block good ones (verified: 42 bad-decrypts + 25 successes in one run) - [x] Greg's key auto-unlocked without external intervention (the headline win) - [ ] `RemoteBunkerSigner.nip44_decrypt` round-trips against an auto-unlocked key (implicit from `13:50Z` smoke; explicit test deferred until bitspire fires a fresh inbound event) - [ ] Webapp / future NIP-46 client regression check (not blocking — same code path as today's manual flow post-unlock) ## Out of scope (separate issues if/when needed) - Per-key passphrase support (per-key passphrase metadata + passphrase map column) - Passphrase rotation (dedicated admin RPC to re-encrypt under a new passphrase) - HSM/hardware-derived passphrase delivery (orthogonal to where the passphrase comes from at unlock time) ## Files touched - `src/daemon/run.ts` — idempotency guard + `maybeAutounlock` method + wedge in `startKeys()` - `docs/AUTOUNLOCK.md` — new, ~150 lines: config surface, behavior, security trade by deployment shape, observability, what's not in scope ## Regtest 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`: ```yaml NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE: ${LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE:-} ``` Parallels the existing `NSEC_BUNKER_DEBUG_TRANSPORT` env line. Currently sitting as `M docker-compose.dev.yml` in the regtest repo — Padreug can commit that separately when ready (or leave as a local dev convenience). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
`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).
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.
fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
030d3cea0f
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.
padreug deleted branch issue-16-autounlock 2026-05-31 13:39:18 +00:00
Sign in to join this conversation.
No reviewers
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!17
No description provided.