0003 — BE re-unlock: hand the keyfile payload across kexec (single prompt)
Status: accepted (2026-06-04) Supersedes: 0002 Reverses: SPEC §17.1 / §16.1 v1 choice — from (a) independent re-unlock to (b) hand-off across the kexec boundary.
Context
keyboot kexecs into each boot environment’s own kernel + initramfs (the ZBM model, SPEC §3) — that is what makes “pick between several root filesystems and kernels” possible. The cost of kexec is that the new kernel starts from zero: the dm-crypt mappings and the imported pool that keyboot set up live in the old kernel’s RAM and are gone the instant the BE kernel boots. So the BE’s initramfs must re-open the LUKS disks and re-import the pool from scratch.
ADR 0002 resolved this with (a): the BE re-derives everything itself — dropbear in the BE initramfs, operator enters the keyfile passphrase a second time over SSH. That is correct and shipped, but it forces the operator to type the same passphrase twice per boot. For a fleet of Debian/Gentoo/Alpine BEs that is the normal path, not an edge case, and the double prompt is a standing UX tax plus a second attack-relevant passphrase-entry point.
The operator has decided the destination is single-prompt: decrypt once in keyboot, then operate (rollback / sync / BE-pick) and boot any BE without re-entering the secret. That requires carrying the already-derived key across the kexec boundary — SPEC §17.1’s deferred choice (b). This ADR adopts it and pins the mechanism and the security envelope.
Threat model (why this is safe enough)
Anchor everything against the baseline: while the box is unlocked, the cleartext key is already in RAM — the 32-byte keyfile payload, and the per-disk LUKS volume keys which dm-crypt keeps resident in kernel memory the whole time the disks are open. Handoff does not introduce “key in RAM”; it adds one more transient copy for the window between kexec and the BE consuming it.
- Attacker who can read RAM while unlocked (cold-boot, DMA/PCILeech, malicious hypervisor): already wins in both designs — the volume keys are in kernel memory regardless, and on a hosted box the host can read guest RAM at will. Handoff’s marginal delta is ~nil.
- Attacker who can tamper at rest: the BE’s kernel+initramfs live inside the encrypted pool (keyboot reads them only after unlocking), so they cannot be pre-tampered without already holding the key — the attack eats its own tail. The one tamperable artifact is keyboot’s own initramfs on the unencrypted ESP; an attacker who owns that captures the passphrase at entry in both designs. So the independent-re-unlock invariant never protected against this attacker either.
Conclusion: handoff does not meaningfully widen attacker capability. The only genuine residual risk is leakage by our own handoff code — an auditable, bounded checklist (see Consequences), not an open-ended exposure. This is what §16.1’s caution was really guarding; we discharge it with the mechanism below plus the audit checklist, rather than by paying the double-prompt forever.
Decision
keyboot hands the 32-byte keyfile payload (not the passphrase, not the per-disk volume keys) to the BE, RAM-only, via an initramfs overlay on the kexec’d image. The BE uses it to open the disks directly and then shreds it.
Mechanism:
- Capture (keyboot unlock, stage-5). While the keyfile container is open,
keyboot unlock --emit-key <path>copies the 32-byte payload from/dev/mapper/keyboot-keyto a tmpfs path (/run/keyboot/handoff.key, mode 0400) before closing the container. tmpfs = RAM; never hits disk. - Inject (stage-9, at kexec). stage-9 builds a tiny uncompressed cpio
containing
etc/keyboot/handoff.keyand appends it to the BE initramfs image in tmpfs (the Linux initramfs loader concatenates archives; later entries overlay earlier — the same mechanism as early-microcode cpios). It kexec-loads the combined image, then shreds/run/keyboot/handoff.keyand the staged copy. The payload never touches the kexec cmdline (which would be world-readable in/proc/cmdlineforever). - Consume (BE,
keyboot be-unlock). If/etc/keyboot/handoff.keyexists, be-unlock uses it directly as the--key-filefor the per-diskcryptsetup luksOpen(open_enrolledalready takes a key-file path) — no passphrase, no container open, no dropbear — then shreds it (overwrite + unlink) immediately after the opens, before the pool import. - Hand the payload, not the passphrase. The reusable human secret (slot 0) never crosses kexec; only the per-install, rotatable machine key does. This is strictly less exposure of the human secret than re-prompting.
The transmitted unit is the keyfile payload, so rotating it (re-enrolling disks) invalidates any captured copy — unlike the passphrase.
Kill switch / fallback. keyboot.handoff=0 on the keyboot cmdline disables
capture+inject; the BE then finds no handoff.key and falls back to ADR 0002’s
independent re-unlock (dropbear-over-SSH / console). The 0002 path is retained
in full as the recovery and opt-out path, not deleted.
Alternatives considered
- Independent re-unlock only (ADR 0002 / §17.1(a)): the status quo. Correct, but two prompts per boot forever. Demoted to fallback, not removed.
- Kernel-keyring handoff (as literally worded in SPEC §17.1(b)): infeasible as written — a kernel keyring is per-kernel and does not survive kexec (the BE boots a fresh kernel with an empty keyring). Rejected on mechanism; the initrd overlay achieves §17.1(b)’s intent by a means that actually crosses the boundary.
- Reserved-memory region preserved across kexec (
memmap=/kho): works, but the key sits at a predictable physical address and relies on correct reserve+clear semantics; more moving parts and more leak surface than a tmpfs file inside a trusted initramfs. Deferred unless the initrd overlay proves insufficient. - Hand the passphrase instead of the payload: simplest BE change, but moves the reusable human secret across the boundary and into a second consumer. Rejected — strictly worse than handing the rotatable payload.
- TPM-seal the keyfile (no handoff, fully unattended): the better long-term story for unattended boxes, but needs a TPM + measured-boot keyboot doesn’t have yet. Deferred; tracked separately.
Consequences
- One prompt per boot. The operator decrypts in keyboot; the BE re-unlock is silent. dropbear in the BE is no longer on the hot path (fallback only).
- Reverses a “do not relitigate” invariant. CLAUDE.md’s “never carry keyboot’s keyfile across the kexec boundary” and SPEC §16.1/§17.1 are updated to choice (b) with this ADR as the rationale of record.
- The handoff code is now safety-critical and must pass this audit checklist
(CI/review gate):
- The payload is never written outside tmpfs and never placed on the
kexec cmdline, in kernel log/
dmesg,/proc, or a crash/kdump path. - Both copies (
/run/keyboot/handoff.keyand the BE’s/etc/keyboot/handoff.key) are overwritten then unlinked after use — keyboot’s after kexec-load, the BE’s right after the disk opens. - The overlay cpio holds only
handoff.key, mode 0400, owner root. keyboot.handoff=0cleanly yields the 0002 fallback (verified by a test).- A wrong/blank/short handoff key fails closed to the fallback, never to an open pool with a wrong key.
- The payload is never written outside tmpfs and never placed on the
kexec cmdline, in kernel log/
- Exposure envelope: a single 32-byte buffer, in RAM, from kexec until be-unlock zeroes it — strictly less than what dm-crypt already keeps resident, and never the passphrase.
- CI: the existing baked-
--test-passphrase-filepath still works (be-unlock prefers handoff > test > prompt). A new gate asserts (i) single-prompt boot via handoff and (ii)keyboot.handoff=0falls back to the 0002 path.
Implementation note
tools/keyboot/src/cli/unlock.rs:open_enrolled/try_opentake a key-file path (already do, internally — generalize the/dev/mapper/<name>construction to an arbitrary path); add--emit-key <path>to theunlockcommand (copy 32 bytes from the open keyfile mapper before close).tools/keyboot/src/cli/be_unlock.rs: prefer/etc/keyboot/handoff.key→ key-file path →open_enrolled, skipping passphrase+container; shred after. Order: handoff > baked test passphrase > stdin/prompt.init/stage-5-luks.sh: pass--emit-key /run/keyboot/handoff.key(unlesskeyboot.handoff=0).init/stage-9-kexec.sh: build the overlay cpio, append to the staged BE initrd, kexec the combined image, shred both key copies.tools/keyboot-install-os/alpine.sh: be-init runs be-unlock non-interactively whenhandoff.keyis present; the dropbear/network/FIFO block (ADR 0002) becomes the no-handoff fallback. Debian/Gentoo BE wiring follows.