fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled

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.
This commit is contained in:
Padreug 2026-05-31 15:34:51 +02:00
commit 030d3cea0f
2 changed files with 35 additions and 12 deletions

View file

@ -35,11 +35,14 @@ the autounlock pass runs:
1. Read the passphrase from the configured source. Failure to read 1. Read the passphrase from the configured source. Failure to read
(missing file, no permission) is fatal at boot. (missing file, no permission) is fatal at boot.
2. Enumerate every row in the `Key` Prisma table where 2. Enumerate the encrypted-at-rest entries in `nsecbunker.json`'s
`deletedAt IS NULL`. `keys` map — entries carrying the `{iv, data}` shape from
3. For each row, call `unlockKey(keyName, passphrase)`. `unlockKey` `create_new_key`. Plain-key entries (`{key: ...}` shape from
is idempotent post-#16: if the key was already unlocked by a `create_account`) are already loaded by the existing
prior pass, it's a no-op. `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` 4. Log per-key INFO on success, WARN on `unlockKey → false`
(typically: wrong passphrase, possibly the key was created under a (typically: wrong passphrase, possibly the key was created under a
historical passphrase that differs from the current one), ERROR on historical passphrase that differs from the current one), ERROR on

View file

@ -278,32 +278,52 @@ class Daemon {
source = `NSEC_BUNKER_AUTOUNLOCK_PASSPHRASE_FILE=${filePath}`; 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(); const start = Date.now();
let success = 0; let success = 0;
for (const key of keys) { for (const keyName of candidates) {
try { try {
const ok = await this.unlockKey(key.keyName, passphrase); const ok = await this.unlockKey(keyName, passphrase);
if (ok) { if (ok) {
console.log(`🔓 autounlock: unlocked ${key.keyName}`); console.log(`🔓 autounlock: unlocked ${keyName}`);
success++; success++;
} else { } else {
console.warn( console.warn(
`⚠️ autounlock: unlockKey returned false for ${key.keyName} ` + `⚠️ autounlock: unlockKey returned false for ${keyName} ` +
`(likely wrong passphrase — encrypted under a different secret?)` `(likely wrong passphrase — encrypted under a different secret?)`
); );
} }
} catch (e: any) { } catch (e: any) {
console.error( console.error(
`❌ autounlock: ${key.keyName} failed: ${e?.message ?? e}` `❌ autounlock: ${keyName} failed: ${e?.message ?? e}`
); );
} }
} }
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
console.log( 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`
); );
} }