Compare commits
No commits in common. "030d3cea0fa4f3904e52bdac01d1e934e87d9632" and "106fa807a18338604bc771a5471928da4f7fd381" have entirely different histories.
030d3cea0f
...
106fa807a1
2 changed files with 0 additions and 273 deletions
|
|
@ -1,143 +0,0 @@
|
||||||
# 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 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
|
|
||||||
throw (typically: corrupted blob).
|
|
||||||
5. Log one summary line:
|
|
||||||
`🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>`.
|
|
||||||
|
|
||||||
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 <keyName>` (INFO, one per success)
|
|
||||||
- `⚠️ autounlock: unlockKey returned false for <keyName> ...` (WARN, one per soft failure)
|
|
||||||
- `❌ autounlock: <keyName> failed: <err.message>` (ERROR, one per throw)
|
|
||||||
- `🔓 autounlock: enabled (source=<env>), unlocked N/M keys in <Xms>` (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)
|
|
||||||
|
|
@ -208,123 +208,6 @@ class Daemon {
|
||||||
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
|
const nsec = nip19.nsecEncode(nostrUtils.hexToBytes(settings.key));
|
||||||
this.loadNsec(keyName, nsec);
|
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<void> {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 keyName of candidates) {
|
|
||||||
try {
|
|
||||||
const ok = await this.unlockKey(keyName, passphrase);
|
|
||||||
if (ok) {
|
|
||||||
console.log(`🔓 autounlock: unlocked ${keyName}`);
|
|
||||||
success++;
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`⚠️ autounlock: unlockKey returned false for ${keyName} ` +
|
|
||||||
`(likely wrong passphrase — encrypted under a different secret?)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(
|
|
||||||
`❌ autounlock: ${keyName} failed: ${e?.message ?? e}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = Date.now() - start;
|
|
||||||
console.log(
|
|
||||||
`🔓 autounlock: enabled (source=${source}), unlocked ${success}/${candidates.length} keys in ${elapsed}ms`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
|
@ -353,19 +236,6 @@ class Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
|
async unlockKey(keyName: string, passphrase: string): Promise<boolean> {
|
||||||
// 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 keyData = this.config.allKeys[keyName];
|
||||||
const { iv, data } = keyData;
|
const { iv, data } = keyData;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue