diff --git a/docs/AUTOUNLOCK.md b/docs/AUTOUNLOCK.md new file mode 100644 index 0000000..1c0552f --- /dev/null +++ b/docs/AUTOUNLOCK.md @@ -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=), unlocked N/M keys in `. + +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 ` (INFO, one per success) +- `⚠️ autounlock: unlockKey returned false for ...` (WARN, one per soft failure) +- `❌ autounlock: failed: ` (ERROR, one per throw) +- `🔓 autounlock: enabled (source=), unlocked N/M keys in ` (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) diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 9497677..743606d 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -208,6 +208,123 @@ class Daemon { const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key)); 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 { + 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() { @@ -236,6 +353,19 @@ class Daemon { } async unlockKey(keyName: string, passphrase: string): Promise { + // 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 { iv, data } = keyData;