feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source #17
Merged
padreug
merged 3 commits from 2026-05-31 13:39:18 +00:00
issue-16-autounlock into dev
3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 030d3cea0f |
fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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.
|
|||
| 7a3cb4f3da |
feat(daemon): boot-time autounlock of encrypted keys (#16)
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.
|
|||
| b6f8abdb23 |
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). |