Captures the deploy hazard found during #27 rollout (cfaun): the
nsecbunkerd<->LNbits pairing is split across both systems, so a full
nsecbunker.db wipe orphans LNbits's signer_config and forces an
identity-changing re-provision. Documents the targeted
'DELETE FROM SigningCondition' procedure, the keys-live-in-json fact,
and the migrate-on-boot no-op (#31).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surveys Signet, Amber, FROSTR, promenade, NDK/rust-nostr/nak against
actual source; records the decision to keep our fork and treat Signet
as a parts donor (NIP-46 wire boundary keeps the signer substitutable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
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 <keyName> (success)
⚠️ autounlock: unlockKey returned false for <keyName> (...) (soft fail)
❌ autounlock: <keyName> failed: <message> (throw)
🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms> (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.