diff --git a/docs/AUTOUNLOCK.md b/docs/AUTOUNLOCK.md deleted file mode 100644 index 1c0552f..0000000 --- a/docs/AUTOUNLOCK.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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 743606d..9497677 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -208,123 +208,6 @@ 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() { @@ -353,19 +236,6 @@ 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;