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.
143 lines
6.8 KiB
Markdown
143 lines
6.8 KiB
Markdown
# Boot-time autounlock
|
|
|
|
`nsecbunkerd` stores each managed key encrypted at rest in
|
|
`nsecbunker.db`. By default, every key is **locked** after the daemon
|
|
starts — clients must drive an `unlock_key` admin RPC against the
|
|
bunker before signing / encrypting / decrypting works for that key.
|
|
|
|
Autounlock is an opt-in feature that, when enabled, reads a
|
|
passphrase from a configured source at boot and unlocks every
|
|
non-soft-deleted key in the `Key` table automatically. This trades
|
|
operational simplicity for a documented security weakening; read
|
|
this whole document before enabling.
|
|
|
|
## Configuration
|
|
|
|
Two mutually-exclusive environment variables:
|
|
|
|
| Var | Meaning |
|
|
|---|---|
|
|
| `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE` | Literal passphrase string. Useful for dev / `docker compose .env` flows. |
|
|
| `NSEC_BUNKER_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 comes from a separate credential store. |
|
|
|
|
**If both are set, the daemon fails loud at boot** with an explicit
|
|
error. Ambiguous config is never allowed to silently pick one.
|
|
|
|
**If neither is set, autounlock is off** — behavior is identical to
|
|
pre-#16: keys remain locked until an admin `unlock_key` RPC fires per
|
|
key per restart.
|
|
|
|
## What happens at boot when autounlock is on
|
|
|
|
After the daemon's existing key-loading passes complete (unencrypted
|
|
keys from in-process config, plain-key entries in `nsecbunker.json`),
|
|
the autounlock pass runs:
|
|
|
|
1. Read the passphrase from the configured source. Failure to read
|
|
(missing file, no permission) is fatal at boot.
|
|
2. Enumerate the encrypted-at-rest entries in `nsecbunker.json`'s
|
|
`keys` map — entries carrying the `{iv, data}` shape from
|
|
`create_new_key`. Plain-key entries (`{key: ...}` shape from
|
|
`create_account`) are already loaded by the existing
|
|
`startKeys()` passes and are skipped here for log clarity.
|
|
3. For each candidate, call `unlockKey(keyName, passphrase)`.
|
|
`unlockKey` is idempotent post-#16: if the key was already
|
|
unlocked by a prior pass, it's a no-op.
|
|
4. Log per-key INFO on success, WARN on `unlockKey → false`
|
|
(typically: wrong passphrase, possibly the key was created under a
|
|
historical passphrase that differs from the current one), ERROR on
|
|
throw (typically: corrupted blob).
|
|
5. Log one summary line:
|
|
`🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>`.
|
|
|
|
The loop is sequential — log clarity > parallelism, the unlock op
|
|
itself is cheap (one ChaCha20 decrypt per key). For 100 keys it's
|
|
milliseconds. If a fleet ever needs the thousands, parallelize then.
|
|
|
|
The NIP-46 client channel doesn't accept RPCs that route to a key
|
|
until that key's `Backend.start()` resolves — which happens inside
|
|
`unlockKey`. So there's no race window where a freshly-restarted
|
|
bunker would say "key locked" to a client while the loop is in
|
|
flight on that key.
|
|
|
|
## The security trade-off
|
|
|
|
Enabling autounlock means **whoever can read the passphrase source
|
|
can recover any key from the bunker disk.** Specifically:
|
|
|
|
- The encrypt-at-rest property of `nsecbunker.db` is *preserved*
|
|
against `cat /var/lib/nsecbunker/*.db` alone — the database holds
|
|
ciphertext + IV per key, not plaintext.
|
|
- The encrypt-at-rest property is *lost* if the attacker also has
|
|
access to the passphrase source. Anyone with read access to the
|
|
passphrase env var, the passphrase file, or the process memory at
|
|
the moment of autounlock can decrypt every key.
|
|
|
|
This is the same trade today's deployments already make when they
|
|
hold the passphrase in `lnbits`'s env to drive `unlock_key` RPCs
|
|
post-restart. Autounlock makes the trade *explicit at the bunker
|
|
level* and *visible per-deployment*, but it doesn't introduce a new
|
|
trust requirement that didn't already exist for any deployment using
|
|
external automation to drive unlocks.
|
|
|
|
### Recommendations by deployment shape
|
|
|
|
- **Dev / regtest / single-host:** literal `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE`
|
|
in `docker compose .env` is fine. The threat model on a dev box
|
|
doesn't justify the file-source ceremony.
|
|
- **Single-tenant production:** passphrase file on a separate
|
|
volume / mount with stricter access. Mount via
|
|
`systemd-LoadCredential` so the file is only readable by the
|
|
bunker process and is materialized from a sops-decrypted source
|
|
at boot. Avoid baking the passphrase into the container image or
|
|
process env list (which leaks into `ps aux`, container labels, etc.).
|
|
- **Multi-tenant / high-security:** leave autounlock off. Orchestrate
|
|
unlock per-restart from an external process that prompts for the
|
|
passphrase out-of-band (hardware token, HSM-derived secret, human
|
|
approval). This preserves the property that bunker startup alone
|
|
doesn't restore crypto capability — a deliberate human action is
|
|
required.
|
|
|
|
## What's *not* in scope
|
|
|
|
These are deliberately out of scope for the autounlock feature.
|
|
Separate issues to file if needed:
|
|
|
|
- **Per-key passphrase support.** The current `Key` table doesn't
|
|
carry per-key passphrase metadata; every `create_new_key(name, passphrase)`
|
|
in our usage today uses the same passphrase
|
|
(`LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE`). The autounlock
|
|
passphrase covers every encrypted key by virtue of this
|
|
single-passphrase invariant. If a deployment ever needs per-key
|
|
passphrases, that's a separate feature (per-key passphrase-selector
|
|
column + per-key passphrase map).
|
|
- **Passphrase rotation.** Re-encrypting every key under a new
|
|
passphrase belongs in a dedicated admin RPC (`rotate_keystore`),
|
|
not in autounlock.
|
|
- **HSM / hardware-derived passphrase delivery.** Orthogonal to
|
|
where the passphrase comes from at unlock time — autounlock just
|
|
reads a string. An HSM integration would land between the
|
|
hardware and the file the bunker reads from.
|
|
|
|
## Observability hooks
|
|
|
|
The autounlock pass emits:
|
|
|
|
- `🔓 autounlock: unlocked <keyName>` (INFO, one per success)
|
|
- `⚠️ autounlock: unlockKey returned false for <keyName> ...` (WARN, one per soft failure)
|
|
- `❌ autounlock: <keyName> failed: <err.message>` (ERROR, one per throw)
|
|
- `🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>` (summary, once)
|
|
|
|
When the optional Prometheus exporter lands, counters
|
|
`nsecbunkerd_keys_unlocked_total` and `nsecbunkerd_keys_locked_total`
|
|
will be reported from the autounlock summary state. The current
|
|
implementation doesn't export metrics — the log line is the
|
|
canonical signal.
|
|
|
|
## See also
|
|
|
|
- `src/daemon/run.ts:Daemon.maybeAutounlock` — implementation
|
|
- `src/daemon/run.ts:Daemon.unlockKey` — the idempotent per-key call
|
|
- `src/daemon/admin/commands/unlock_key.ts` — the admin-RPC wrapper for manual unlock
|
|
- aiolabs/nsecbunkerd#16 — issue with full design rationale + acceptance criteria
|
|
- aiolabs/nsecbunkerd#15 — NDK 3.0.3 bump (the structural fix this builds on)
|