Boot-time autounlock of encrypted keys from a configured passphrase source #16
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Why
Today every bunker restart (container rebuild, NDK bump, host reboot, config change, OOM) leaves all encrypted keys locked on disk. Each lnbits-side
RemoteBunkerSigneraccount, each webapp NIP-46 client, each future bunker-backed extension then needs an out-of-bandunlock_keyadmin RPC against the bunker before any signing / encrypting / decrypting works. With one operator it's a 30-second nuisance. With N operators it's an O(N) manual step per restart and a guaranteed forgot-to-unlock-operator-7 bug.The right place to fix this is at the bunker — keys are nsecbunkerd's domain, the lock state belongs to it, and ANY NIP-46 consumer benefits without per-consumer orchestration code. Filed as the architecturally-correct path per coord-log
2026-05-31T13:30Z(lnbits → all) where the design was ratified.Diagnosis context: coord log
2026-05-31T13:30Z(the ask) +2026-05-31T13:50Z(NDK 3.0.3 smoke closed, autounlock is the natural next paper-cut). Both inarchive/2026-05-31-pre-rotation.mdwill need to be re-anchored once those entries roll off the active log.Design surface
Configuration — two env vars, mutually exclusive
NSECBUNKER_AUTOUNLOCK_PASSPHRASE— literal passphrase string. Useful for dev / docker compose.envflows.NSECBUNKER_AUTOUNLOCK_PASSPHRASE_FILE— path to a file containing the passphrase (newline-trimmed at read). Idiomatic for sops / systemd-LoadCredential / k8s-secret / external secrets-manager flows where the passphrase lives in a separate credential store.If both are set, fail loud at boot. If neither is set, autounlock is off by default — preserves today's "explicit per-restart unlock" posture for security-conscious deployments that want crypto-capability restoration to gate on a human action.
Boot-sequence placement
The unlock loop has to wedge between two existing milestones:
Cleanest gate: hold
Backend.start()until the autounlock loop completes (or hold each per-keyBackend.start()until that key's unlock returns). The existingDaemon.startKeys()loop insrc/daemon/run.tsis the obvious place to wedge — call autounlock-per-key inline before invokingstartKey(name, nsec).Unlock loop (pseudocode from lnbits's design)
Sequential is fine — log clarity > parallelism, unlock is cheap ChaCha20 + per-key Backend startup is the real cost. Don't let one bad row block the rest of the fleet.
Single-passphrase invariant (document, don't promise multi-key)
Every
create_new_key(name, passphrase)in our usage today is called with the same passphrase (LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE). TheKeytable doesn't carry per-key passphrase metadata, so a single autounlock passphrase covers every row. Document this in the config docstring +docs/AUTOUNLOCK.mdso future-us doesn't accidentally promise multi-passphrase support. Per-key passphrase support is a separate feature (separate column + per-key map), explicitly out of scope here.Idempotency of
unlockKeyunlockKey(name, passphrase)should be safe to call on an already-unlocked key. Spot-check the existing impl atsrc/daemon/run.ts— if it throws on "already unlocked", small fix to swallow that case. Idempotency matters because:Observability
autounlock: enabled (source=NSECBUNKER_AUTOUNLOCK_PASSPHRASE_FILE), unlocked N/M keys in <Xms>autounlock: unlocked <keyName>)nsecbunkerd_keys_unlocked_total/nsecbunkerd_keys_locked_totalmetrics for any future Prometheus exporter — not blocking, just useful to design forSecurity disclaimer (
docs/AUTOUNLOCK.md)Spell out the trade explicitly so deployments choose deliberately:
cat /var/lib/nsecbunker/*.dbalone, but lost if the attacker ALSO has the passphrase source."Acceptance criteria
NSECBUNKER_AUTOUNLOCK_PASSPHRASE+NSECBUNKER_AUTOUNLOCK_PASSPHRASE_FILEenv vars wiredunlock_keyadmin RPC per key per restart)Keytable, callunlockKeyper row, log per-key result + one summary lineunlockKey()is idempotent (safe to re-call against an already-unlocked key)docs/AUTOUNLOCK.mdwith the security trade-offRemoteBunkerSigner.nip44_decryptround-trips against bothOut of scope (separate issues if/when needed)
keystorePassphraseSelector+ passphrase map)Files for reference
src/daemon/run.ts— existingunlockKey(keyName, passphrase)method,Daemon.startKeys()loop,Daemon.start()orchestrationsrc/daemon/admin/commands/unlock_key.ts— admin RPC wrapper, reference for existing unlock-error handlingprisma/schema.prisma—Keytable (already hasdeletedAtfor the soft-delete skip)refs: aiolabs/nsecbunkerd#15 (merged — NDK 3.0.3 bump, the structural fix the autounlock builds on), coord log
2026-05-31T13:30Z(lnbits design ratification, archived), coord log2026-05-31T13:50Z(NDK 3 smoke closed via operator publish — first-ever workingnip44_encryptend-to-end)