luks: detached LUKS header support (rd.luks.header=, crypttab header=)#319
luks: detached LUKS header support (rd.luks.header=, crypttab header=)#319pilotstew wants to merge 18 commits intoanatol:masterfrom
Conversation
Parses fido2-device=auto and tpm2-device=auto from rd.luks.options, deferring the keyboard passphrase prompt until the token attempt fails or token-timeout elapses (default 30s). Prevents simultaneous keyboard and FIDO2/TPM2 unlock flows when a hardware token is enrolled. Also adds token-timeout=DURATION support (bare integer = seconds, matching systemd semantics; 0 = wait forever).
Generator bundles /etc/crypttab.initramfs as /etc/crypttab in the initramfs when the file exists on the host (opt-in by file presence, same pattern as mkinitcpio/dracut). Referenced keyfiles and detached LUKS headers are automatically bundled — no extra_files entry needed. Init reads /etc/crypttab at boot and merges entries into the LUKS unlock queue. Supported options: discard, same-cpu-crypt, submit-from-crypt-cpus, no-read-workqueue, no-write-workqueue, fido2-device=auto, tpm2-device=auto, token-timeout=, key-slot=, header=, noauto. Kernel cmdline rd.luks.* parameters take precedence over crypttab entries for the same device. Also fixes recoverSystemdFido2Password to select on a done channel so goroutines exit cleanly when the device is unlocked by another means, and adds token gating so systemd-fido2/tpm2 tokens are only attempted when the corresponding fido2-device=/tpm2-device= option is set.
nofail, keyfile-offset=, keyfile-size=, tries=, and keyfile-on-separate-device are silently ignored; mark them with TODOs so they are easy to find later.
Parse tries=N from crypttab options and enforce the limit in requestKeyboardPassword. tries=0 (default) retains the existing unlimited retry behaviour. Cached passphrases from other volumes are still tried silently before the limit is applied.
When nofail is set, any luksOpen error is logged as a warning and boot continues. Also introduces a senderWg that tracks every goroutine able to send to the volumes channel; when all give up the channel is closed so luksOpen unblocks instead of hanging forever. This fixes a pre-existing deadlock for token-only setups where all tokens fail and there are no keyboard slots, regardless of nofail.
Parse keyfile-offset= and keyfile-size= options and honour them when reading the keyfile. Replaces os.ReadFile with a readKeyfile helper that seeks to the offset and reads at most keyfileSize bytes (0 = read to end), matching the systemd-cryptsetup behaviour.
Implement the keyfile:UUID=<dev> / keyfile:LABEL=<dev> / etc. syntax
from crypttab(5) that places the keyfile on a removable block device
rather than bundling it in the initramfs.
At unlock time booster:
- Waits for the named device to appear (up to keyfile-timeout= or
MountTimeout) via BTRFS_IOC_DEVICES_READY-style polling
- Mounts it read-only at /run/booster/keydev-<name>
- Reads the keyfile (honoring keyfile-offset= / keyfile-size=)
- Unmounts immediately after the key is read
Falls back to keyboard passphrase if the device is not found within
the timeout, matching systemd-cryptsetup behaviour.
The same keyfile field syntax is also supported on the rd.luks.key=
kernel command-line parameter.
The generator skips bundling the keyfile into the initramfs image when
it detects a device reference suffix (UUID=/LABEL=/PARTUUID=/PARTLABEL=).
New unit tests cover parseKeyfileField, keyfile-timeout= parsing, the
same-device error, and the generator-side isKeyfileOnDevice helper.
When two LUKS volumes appear simultaneously (e.g. btrfs RAID1 across two encrypted drives), both goroutines could check an empty passphrase cache and proceed to readPassword before either finished PBKDF. The result: two prompts for what should be a single-passphrase setup, and a deadlock when the second prompt is never answered. Root cause: inputMutex was released before PBKDF, so the second goroutine could acquire the console and start prompting while the first was still deriving keys. Fix: hold inputMutex through PBKDF on the console path. When the next goroutine acquires the mutex it re-checks the cache and finds the newly added password, unlocking silently without a second prompt. Add readPasswordLocked (caller holds inputMutex) to support this; the existing readPassword wrapper is unchanged for other callers. Verified with QEMU integration tests: btrfs RAID1 across two LUKS2 volumes with identical passphrases prompts exactly once; with distinct passphrases both are prompted in device-enumeration order.
Add integration tests exercising the new crypttab features and the
passphrase cache fix end-to-end in QEMU:
- TestLUKS2CrypttabPassphrase: unlock via /etc/crypttab only
- TestLUKS2NofailCrypttab: nofail entry for absent device
- TestLUKS2CrypttabKeyfileOffsetSize: keyfile-offset= / keyfile-size=
- TestLUKS2KeyfileOnDeviceCmdline: rd.luks.key= with separate keydev
- TestLUKS2KeyfileOnDeviceCrypttab: same via /etc/crypttab
- TestBtrfsRaid1LuksSharedPassphrase: btrfs RAID1, two LUKS2 drives
with the same passphrase — prompts once, cache unlocks the second
- TestBtrfsRaid1LuksDifferentPassphrases: btrfs RAID1, distinct
passphrases — both prompted in device-enumeration order
Add asset generators:
- luks_keyfile_offset.sh: LUKS2 with 512-byte preamble keyfile
- luks_keyfile_device.sh: LUKS2 with keyfile on a separate vfat image
- luks_btrfs_two.sh: two LUKS2 images forming a single btrfs RAID1
All tests verified passing under QEMU.
OpenWithHeader support (needed for detached LUKS headers) was merged into the upstream anatol/luks.go repository. Remove the replace directive and bump to v0.0.0-20260311221814-15ee027e288c.
Allows specifying a detached LUKS header at boot via: rd.luks.header=<UUID>=<path> where UUID identifies the LUKS device and path is the header file bundled in the initramfs. Complements the existing crypttab header= option with an equivalent kernel command line interface.
When a LUKS data device has no embedded header (detached header mode), probeLuks returns nil and the device is left with format="", causing it to be silently skipped during boot. Add probeLuksHeader to read LUKS metadata from a header file path, and use it in addBlockDevice: when a new block device has no detected format, check every luksMapping with a bundled header= file. If the header's UUID matches the mapping's device ref, adopt the data device as LUKS and hand it to handleLuksBlockDevice. Also fix TestLUKS2DetachedHeaderCmdline to pass the header file as extraFiles so rd.luks.header= can find it in the initramfs.
| // systemCrypttabPath returns the host's /etc/crypttab path, which may be | ||
| // overridden by the BOOSTER_SYSTEM_CRYPTTAB environment variable for testing. | ||
| func systemCrypttabPath() string { | ||
| if p := os.Getenv("BOOSTER_SYSTEM_CRYPTTAB"); p != "" { |
There was a problem hiding this comment.
If this file override is purely for testing then the logic must not leak to the production build.
Testing should either use mocks to override the function, or this function should add a check e.g. if testing.Testing() && os.Getenv(.....
There was a problem hiding this comment.
It's actually not needed at all. Things have gotten a bit sloppy. Starting with the conceptual changes away from crypttab.initramfs to x.initrd-attach followed by the PR split. I have been busy the last couple days and need to get a more proper review in. Since we parse everything from crypttab now the parser for crypttab.initramfs and its tests can all be removed (and you're correct it was a testing leak into production). I'll push a commit for this shortly.
| compression string | ||
| timeout time.Duration | ||
| extraFiles []string | ||
| crypttabFile string // explicit crypttab path (--crypttab flag); empty = read /etc/crypttab filtered by x-initrd.attach |
There was a problem hiding this comment.
So the generator has an option to override the crypttab file. Can we use it for testing instead of introducing a new environment variable?
| rootRw = true | ||
| case "rd.luks.options": | ||
| for o := range strings.SplitSeq(value, ",") { | ||
| if strings.HasPrefix(o, "fido2-device=") { |
There was a problem hiding this comment.
Is parsing fido2-device options related here? Are these options from the crypttab?
There was a problem hiding this comment.
b39c916 adds the fido2/tpm2 fields to luksMapping and reworks luksOpen() — agreed it belongs in the fido2 PR. The issue is that f8cfbe2 (the first crypttab commit) was written on top of it and modifies the same struct and function, so dropping b39c916 causes conflicts throughout the luks.go history.
I can rewrite the chain to remove the dependency
| seenHidrawDevices[devName] = true | ||
|
|
||
| password, err := recoverFido2Password(devName, node.Credential, node.Salt, node.RelyingParty, node.PinRequired, node.UserPresenceRequired, node.UserVerificationRequired) | ||
| maxAttempts := 1 |
There was a problem hiding this comment.
This file changes look related to fido2 PR.
There was a problem hiding this comment.
Rather than incorrectly adding crypttabHasFido2 into this commit sequence and then removing it with generator: remove fido2plugin bundling from pr/header commit, it is better to revert the fido changes in the original commit in this sequence.
There was a problem hiding this comment.
To be honest, the goal was crypttab support and the fido2 changes are difficult to cleanly separate — specifically the fido2-device=/tpm2-device= options are part of the crypttab spec itself, so the init-side code for both lives in the same place.
If you'd prefer fully independent PRs in order:
- fido-assert → libfido2
- detached header
- crypttab
the cleanest way to get there would be starting fresh without the shared history. Happy to do that if it makes review easier.
There was a problem hiding this comment.
Having independent PR will make the review process easier and the PRs can be developed and reviewed in parallel. Especially given that fido-asset needs to be reworked to a more pluggable solution.
I was thinking that it should be relatively easy to modify this PR and drop all commits before
go.mod: update luks.go leaving only LUKS header related changes.
There was a problem hiding this comment.
Give me a few days, maybe sooner. Not sure what my schedule is going to be like. I want to rebuild this as discussed. It will allow me to redevelop and test these independently. The luks.go changes are good. And thanks Anatol
There was a problem hiding this comment.
Ack, I picked up the race condition fix and landed it master. The crypttab and and luke header changes look good except a few comments above. Looking forward to get the support for it merged. As well as the libfido refactoring.
…ystem
Extends the existing header= (crypttab) and rd.luks.header= (cmdline)
support to cover two new header locations:
- Raw block device: header=/dev/sdb (crypttab) or
rd.luks.header=UUID=/dev/sdb (cmdline). Booster waits for the device
to appear and passes it directly to cryptsetup --header.
- File on a separate filesystem: rd.luks.header=UUID=/path:DEVREF
(cmdline only). Booster mounts the device read-only, reads the header
file, then unmounts before unlocking.
Implementation:
- acquireHeader() in luks.go handles all three header forms (bundled
file, raw block device, file on device) by mounting when needed.
- pendingDevices list in main.go parks format='' data devices that
cannot yet be matched; retryPendingDevices() replays them when a
header device arrives.
- probeHeaderOnDevice() non-blockingly checks processedBlkInfos for a
ready header device, mounts it, and reads the LUKS UUID to match
UUID-based data device refs.
- parsePathWithDeviceRef() in crypttab.go is extracted as a shared
helper for parseKeyfileField and parseHeaderField.
- isHeaderOnDevice() in generator/crypttab.go prevents the generator
from bundling headers that live on runtime devices.
- BOOSTER_SYSTEM_CRYPTTAB env var lets the generator read a custom
crypttab path (used by tests running as non-root).
- --crypttab description updated to reflect actual filtering behaviour.
Unit tests: - TestParseHeaderField*: parseHeaderField for all ref types - TestParseCrypttabHeaderOnDevice / HeaderColonSyntaxStoredVerbatim - TestIsHeaderOnDevice*: generator isHeaderOnDevice helper - TestAppendCrypttabHeaderRawDeviceNotBundled: generator skip-bundling - TestAppendCrypttabSystemPath: BOOSTER_SYSTEM_CRYPTTAB env var path QEMU integration tests: - TestLUKS2DetachedHeaderRawDevice: crypttab header=/dev/vda (raw block) - TestLUKS2DetachedHeaderCmdlineOnDevice: rd.luks.header=UUID=/root.hdr:UUID=devuuid (header file on a separate ext4 device — exercises acquireHeader + probeHeaderOnDevice, the only previously-untested code path) New test asset: luks2.detached_header.hdrdev.img — 10 MB ext4 image containing the detached header at /root.hdr, created by generators/luks_detached_header_device.sh.
…file device syntax
Update rd.luks.header= and crypttab header= entries in the manpage to cover the three supported forms: bundled initramfs file, raw block device (/dev/sdb), and file on a separate device (/path:UUID=xxx via cmdline).
--crypttab now specifies an alternate path to a standard crypttab file; x-initrd.attach filtering applies in all cases. The separate appendCrypttabFrom (no filtering) and appendCrypttab/systemCrypttabPath (BOOSTER_SYSTEM_CRYPTTAB env var) are removed in favour of a single appendCrypttabFiltered used directly by both the generator and tests.
|
Closing in favour of #323, which rebuilds this as a focused, standalone PR covering only the |
Summary
Adds support for detached LUKS headers — LUKS metadata stored separately
from the encrypted data device. Depends on PR #318 (crypttab support).
Kernel cmdline (
rd.luks.header=):crypttab (
header=option in/etc/crypttab.initramfs):Note:
header=UUID=...:/pathsyntax (file on separate device) is supportedon the kernel cmdline but not in crypttab — standard crypttab does not define
that syntax, so it is out of scope here.
Header locations supported:
/path/to/fileUUID=<uuid>:/pathLABEL=<lbl>:/path/dev/sdX/path/to/file/dev/sdXThe generator automatically bundles header files referenced in
/etc/crypttab.initramfs; raw block device and remote-filesystem headersare resolved at boot time.
Uses
luks.OpenWithHeaderfrom anatol/luks.go PR #16.Test plan
go test ./init/... ./generator/...go test -v -run "TestLuksDetachedHeader"(bundled file, cmdline + crypttab)go test -v -run "TestLuksHeaderOnDevice"(raw block device + filesystem)