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
2 changed files with 273 additions and 0 deletions

143
docs/AUTOUNLOCK.md Normal file
View file

@ -0,0 +1,143 @@
# 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)

View file

@ -208,6 +208,123 @@ class Daemon {
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key)); const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
this.loadNsec(keyName, nsec); this.loadNsec(keyName, nsec);
} }
// Boot-time autounlock of encrypted-at-rest keys. Off by default;
// enabled by setting NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE or
// NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE. See docs/AUTOUNLOCK.md
// for the security trade-off and aiolabs/nsecbunkerd#16 for the
// design rationale.
await this.maybeAutounlock();
}
/**
* Boot-time autounlock for encrypted keys.
*
* Reads a passphrase from one of two mutually exclusive env vars:
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE literal passphrase
* - NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE path to a file containing
* the passphrase (newline-trimmed)
*
* If neither is set, this is a no-op the deployment opted out and
* keys remain locked until an admin `unlock_key` RPC fires per key
* per restart (today's default).
*
* If both are set, throws at boot ambiguous config.
*
* Otherwise: enumerates `Key` table rows where `deletedAt IS NULL`,
* calls `unlockKey(keyName, passphrase)` per row. Sequential, with
* continue-on-error so one bad row doesn't block the rest of the
* fleet. Per-key INFO/WARN/ERROR log + one summary line at the end.
*
* `unlockKey` is idempotent post-#16 calling it against a key that
* was already loaded via the unencrypted paths above is safe (returns
* true without spawning a duplicate Backend).
*
* Single-passphrase invariant: every `create_new_key(name, passphrase)`
* uses the same passphrase in our usage today, so one autounlock
* passphrase covers every encrypted key. Per-key passphrase support
* is a separate feature (out of scope see issue #16).
*/
async maybeAutounlock(): Promise<void> {
const literal = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE;
const filePath = process.env.NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE;
if (literal && filePath) {
throw new Error(
'Autounlock: NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE and ' +
'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE are mutually exclusive. ' +
'Set exactly one (or neither, to leave autounlock off).'
);
}
if (!literal && !filePath) {
return; // autounlock off (default)
}
let passphrase: string;
let source: string;
if (literal) {
passphrase = literal;
source = 'NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE';
} else {
const fs = await import('fs');
try {
passphrase = fs.readFileSync(filePath!, 'utf8').replace(/\r?\n$/, '');
} catch (e: any) {
throw new Error(
`Autounlock: failed to read passphrase file ${filePath}: ${e.message}`
);
}
source = `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE=${filePath}`;
}
// Enumerate encrypted-at-rest keys from `config.allKeys`. The
// Prisma `Key` table is only populated by the NIP-05 `create_account`
// path (which stores keys plain-at-rest in nsecbunker.json);
// `create_new_key` provisions keys with the `{iv, data}` encrypted
// shape directly into the JSON blob without a Prisma row. So the
// canonical "what's encrypted at rest" source is `allKeys` filtered
// to entries carrying `iv`+`data` — that's the set of keys for
// which the manual `unlock_key` admin RPC was previously required
// per restart, and exactly the set we want to autounlock here.
//
// Plain-key entries (`{key: "..."}` shape, populated by `create_account`)
// were already loaded by the second loop in `startKeys` above and
// appear in `activeKeys` — `unlockKey`'s idempotency guard makes
// re-calling them safe but unnecessary, so we filter them out for
// log clarity.
const candidates = Object.entries(this.config.allKeys || {})
.filter(([, entry]) =>
entry && typeof entry === 'object' && 'iv' in entry && 'data' in entry
)
.map(([keyName]) => keyName);
const start = Date.now();
let success = 0;
for (const keyName of candidates) {
try {
const ok = await this.unlockKey(keyName, passphrase);
if (ok) {
console.log(`🔓 autounlock: unlocked ${keyName}`);
success++;
} else {
console.warn(
`⚠️ autounlock: unlockKey returned false for ${keyName} ` +
`(likely wrong passphrase — encrypted under a different secret?)`
);
}
} catch (e: any) {
console.error(
`❌ autounlock: ${keyName} failed: ${e?.message ?? e}`
);
}
}
const elapsed = Date.now() - start;
console.log(
`🔓 autounlock: enabled (source=${source}), unlocked ${success}/${candidates.length} keys in ${elapsed}ms`
);
} }
async start() { async start() {
@ -236,6 +353,19 @@ class Daemon {
} }
async unlockKey(keyName: string, passphrase: string): Promise<boolean> { async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
// Idempotency guard: if a Backend instance already exists for this
// keyName, the key is already unlocked and the relay subscription
// for its kind-24133 channel is already active. Calling startKey
// again would spawn a SECOND Backend with a duplicate subscription
// — wire events would be handled twice, with race/amplification
// hazards on the response side. Return success without re-running
// startKey so callers (admin `unlock_key` RPC, autounlock loop,
// belt-and-suspenders fallback paths) can fire safely against
// already-unlocked keys.
if (this.activeKeys[keyName]) {
return true;
}
const keyData = this.config.allKeys[keyName]; const keyData = this.config.allKeys[keyName];
const { iv, data } = keyData; const { iv, data } = keyData;