Skip to content

luks: detached LUKS header support (rd.luks.header=, crypttab header=)#319

Closed
pilotstew wants to merge 18 commits intoanatol:masterfrom
pilotstew:pr/header
Closed

luks: detached LUKS header support (rd.luks.header=, crypttab header=)#319
pilotstew wants to merge 18 commits intoanatol:masterfrom
pilotstew:pr/header

Conversation

@pilotstew
Copy link
Copy Markdown
Contributor

@pilotstew pilotstew commented Mar 14, 2026

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=):

rd.luks.header=/path/to/header.img
rd.luks.header=UUID=<fs-uuid>:/path/in/fs
rd.luks.header=LABEL=<lbl>:/path/in/fs
rd.luks.header=/dev/sdX

crypttab (header= option in /etc/crypttab.initramfs):

# Plain file bundled into initramfs or raw block device only:
crypt-data  UUID=<data-uuid>  none  header=/path/to/header.img
crypt-data  UUID=<data-uuid>  none  header=/dev/sdX

Note: header=UUID=...:/path syntax (file on separate device) is supported
on the kernel cmdline but not in crypttab — standard crypttab does not define
that syntax, so it is out of scope here.

Header locations supported:

Context Syntax Description
cmdline /path/to/file bundled into initramfs (absolute path)
cmdline UUID=<uuid>:/path file on a separate filesystem device
cmdline LABEL=<lbl>:/path file on a separate filesystem device
cmdline /dev/sdX raw block device (header at start of device)
crypttab /path/to/file bundled into initramfs
crypttab /dev/sdX raw block device

The generator automatically bundles header files referenced in
/etc/crypttab.initramfs; raw block device and remote-filesystem headers
are resolved at boot time.

Uses luks.OpenWithHeader from anatol/luks.go PR #16.

Test plan

  • Unit tests: go test ./init/... ./generator/...
  • QEMU integration tests:
    • go test -v -run "TestLuksDetachedHeader" (bundled file, cmdline + crypttab)
    • go test -v -run "TestLuksHeaderOnDevice" (raw block device + filesystem)
  • Manual: create a LUKS volume with detached header, add to crypttab, boot

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.
Comment thread generator/crypttab.go Outdated
// 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 != "" {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(.....

Copy link
Copy Markdown
Contributor Author

@pilotstew pilotstew Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread generator/generator.go
compression string
timeout time.Duration
extraFiles []string
crypttabFile string // explicit crypttab path (--crypttab flag); empty = read /etc/crypttab filtered by x-initrd.attach
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the generator has an option to override the crypttab file. Can we use it for testing instead of introducing a new environment variable?

Comment thread init/cmdline.go
rootRw = true
case "rd.luks.options":
for o := range strings.SplitSeq(value, ",") {
if strings.HasPrefix(o, "fido2-device=") {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is parsing fido2-device options related here? Are these options from the crypttab?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread init/luks.go
seenHidrawDevices[devName] = true

password, err := recoverFido2Password(devName, node.Credential, node.Salt, node.RelyingParty, node.PinRequired, node.UserPresenceRequired, node.UserVerificationRequired)
maxAttempts := 1
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file changes look related to fido2 PR.

Comment thread generator/crypttab.go
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. fido-assert → libfido2
  2. detached header
  3. crypttab

the cleanest way to get there would be starting fresh without the shared history. Happy to do that if it makes review easier.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@pilotstew pilotstew Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
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.
@pilotstew
Copy link
Copy Markdown
Contributor Author

Closing in favour of #323, which rebuilds this as a focused, standalone PR covering only the rd.luks.header= kernel parameter. Crypttab support will follow in a separate PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants