From b6f8abdb2373d9b5e22037eba513a9595491522f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 31 May 2026 15:29:07 +0200 Subject: [PATCH 1/3] fix(daemon): make unlockKey idempotent (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unlockKey(keyName, passphrase)` previously had no short-circuit on re-entry — calling it against an already-unlocked key would happily run through the full path: 1. decryptNsec (cheap, same result) 2. overwrite this.activeKeys[keyName] with the same nsec 3. call startKey(keyName, nsec) → spawn a SECOND Backend instance Step 3 is the actual hazard. Each Backend opens its own NIP-46 kind- 24133 subscription with the relay, scoped to the key's pubkey. Two Backends → duplicate subscription → wire events delivered twice and each handler races to publish its response. Response amplification + ordering hazards downstream, plus a slow leak of NDK subscription state every time unlock fires. This bug was latent under the manual-unlock posture today (admins rarely re-issue unlock_key for the same name in one session) but becomes load-bearing for #16's autounlock loop, which is designed to run alongside the existing startKeys() loops and may legitimately encounter a key that was already loaded via the unencrypted-config path. Belt-and-suspenders lnbits-side scripts + future periodic "re-unlock sweeps for paranoia" can also fire this. Fix: short-circuit on `this.activeKeys[keyName]` already set. Return true so callers can rely on "after this call returns, the key is unlocked and ready" regardless of whether work was done. Doesn't break the manual flow (still unlocks first-time), doesn't change the failure path (corrupt blob / wrong passphrase still throws), just closes the re-entry foot-gun. Refs aiolabs/nsecbunkerd#16 (autounlock — this is the idempotency sub-task lnbits flagged in the design surface). --- src/daemon/run.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 9497677..f91dbd7 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -236,6 +236,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; From 7a3cb4f3da5d113180d88212b50888ce4de81da3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 31 May 2026 15:31:25 +0200 Subject: [PATCH 2/3] feat(daemon): boot-time autounlock of encrypted keys (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in autounlock to the daemon's boot sequence. Closes the "O(N) manual unlock_key RPC per bunker restart" paper-cut without breaking the secure-by-default posture: deployments that want every restart to gate crypto capability on a human action keep that property by leaving both env vars unset. Configuration — two mutually exclusive env vars: NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE literal passphrase NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE path (newline-trimmed) Both set → fail loud at boot. Neither set → no-op (default, behavior unchanged from pre-#16). Var names follow the bunker's existing NSEC_BUNKER_* convention (see NSEC_BUNKER_DEBUG_TRANSPORT, NSEC_BUNKER_DISABLE_WATCHDOG); the design issue spec'd NSECBUNKER_* but aligning with the existing prefix matters more for operator muscle-memory than matching the issue text verbatim. Implementation: - `Daemon.maybeAutounlock()` wedged at the tail of `startKeys()`. Inherits the relay-subscription lifecycle (EOSE-awaited per #9) that the existing per-key startKey calls established, so there's no "client sees key locked" race window. - Enumeration via `prisma.key.findMany({ where: { deletedAt: null } })` — Key table is the canonical source of truth for what keys exist on the bunker; respects soft-delete. - Per-key call to the existing `unlockKey(keyName, passphrase)`, which is idempotent post-#16 — encrypted-at-rest keys get unlocked on first call; rows already loaded via the unencrypted-config passes above are no-ops. - Sequential loop with continue-on-error. One bad row (corrupted blob, key encrypted under a historical passphrase, etc.) doesn't block the rest of the fleet. Per-key INFO/WARN/ERROR + one summary line. - File-source error (missing path, permission denied) is fatal at boot — same severity as a misconfig. Observability output: 🔓 autounlock: unlocked (success) ⚠️ autounlock: unlockKey returned false for (...) (soft fail) ❌ autounlock: failed: (throw) 🔓 autounlock: enabled (source=), unlocked N/M keys in (summary) Single-passphrase invariant: every `create_new_key(name, passphrase)` in our usage today uses the same passphrase (LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE on the lnbits side), so one autounlock passphrase covers every encrypted key. Per-key passphrase support is a separate feature (out of scope — see #16 "out of scope" section + docs/AUTOUNLOCK.md "What's not in scope"). `docs/AUTOUNLOCK.md` ships alongside: usage, the security trade spelled out by deployment shape, observability hooks, what's deliberately not in scope. Required-reading link before any operator flips the env var on for a production-shaped deployment. Refs aiolabs/nsecbunkerd#16. Builds on idempotent unlockKey from the previous commit on this branch. --- docs/AUTOUNLOCK.md | 140 +++++++++++++++++++++++++++++++++++++++++++++ src/daemon/run.ts | 97 +++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 docs/AUTOUNLOCK.md diff --git a/docs/AUTOUNLOCK.md b/docs/AUTOUNLOCK.md new file mode 100644 index 0000000..6e0bd1b --- /dev/null +++ b/docs/AUTOUNLOCK.md @@ -0,0 +1,140 @@ +# 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 every row in the `Key` Prisma table where + `deletedAt IS NULL`. +3. For each row, 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 f91dbd7..7945867 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -208,6 +208,103 @@ 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}`; + } + + const keys = await prisma.key.findMany({ where: { deletedAt: null } }); + const start = Date.now(); + let success = 0; + + for (const key of keys) { + try { + const ok = await this.unlockKey(key.keyName, passphrase); + if (ok) { + console.log(`🔓 autounlock: unlocked ${key.keyName}`); + success++; + } else { + console.warn( + `⚠️ autounlock: unlockKey returned false for ${key.keyName} ` + + `(likely wrong passphrase — encrypted under a different secret?)` + ); + } + } catch (e: any) { + console.error( + `❌ autounlock: ${key.keyName} failed: ${e?.message ?? e}` + ); + } + } + + const elapsed = Date.now() - start; + console.log( + `🔓 autounlock: enabled (source=${source}), unlocked ${success}/${keys.length} keys in ${elapsed}ms` + ); } async start() { From 030d3cea0fa4f3904e52bdac01d1e934e87d9632 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 31 May 2026 15:34:51 +0200 Subject: [PATCH 3/3] fix(daemon): autounlock walks config.allKeys, not prisma.key (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/AUTOUNLOCK.md | 13 ++++++++----- src/daemon/run.ts | 34 +++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/docs/AUTOUNLOCK.md b/docs/AUTOUNLOCK.md index 6e0bd1b..1c0552f 100644 --- a/docs/AUTOUNLOCK.md +++ b/docs/AUTOUNLOCK.md @@ -35,11 +35,14 @@ 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 every row in the `Key` Prisma table where - `deletedAt IS NULL`. -3. For each row, call `unlockKey(keyName, passphrase)`. `unlockKey` - is idempotent post-#16: if the key was already unlocked by a - prior pass, it's a no-op. +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 diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 7945867..743606d 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -278,32 +278,52 @@ class Daemon { source = `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE=${filePath}`; } - const keys = await prisma.key.findMany({ where: { deletedAt: null } }); + // 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 key of keys) { + for (const keyName of candidates) { try { - const ok = await this.unlockKey(key.keyName, passphrase); + const ok = await this.unlockKey(keyName, passphrase); if (ok) { - console.log(`🔓 autounlock: unlocked ${key.keyName}`); + console.log(`🔓 autounlock: unlocked ${keyName}`); success++; } else { console.warn( - `⚠️ autounlock: unlockKey returned false for ${key.keyName} ` + + `⚠️ autounlock: unlockKey returned false for ${keyName} ` + `(likely wrong passphrase — encrypted under a different secret?)` ); } } catch (e: any) { console.error( - `❌ autounlock: ${key.keyName} failed: ${e?.message ?? e}` + `❌ autounlock: ${keyName} failed: ${e?.message ?? e}` ); } } const elapsed = Date.now() - start; console.log( - `🔓 autounlock: enabled (source=${source}), unlocked ${success}/${keys.length} keys in ${elapsed}ms` + `🔓 autounlock: enabled (source=${source}), unlocked ${success}/${candidates.length} keys in ${elapsed}ms` ); }