From 030d3cea0fa4f3904e52bdac01d1e934e87d9632 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 31 May 2026 15:34:51 +0200 Subject: [PATCH] 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` ); }