feat(#16): boot-time autounlock of encrypted keys from a configured passphrase source #17
2 changed files with 35 additions and 12 deletions
fix(daemon): autounlock walks config.allKeys, not prisma.key (#16)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
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.
commit
030d3cea0f
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue