Disk encryption and boot integrity

LUKS2, unlocking, Secure Boot and lockdown.

Intermediate16 min · lesson 12 of 24

Disk encryption answers a threat the controls so far do not touch: someone who has the disk itself, not a login. A stolen laptop, a decommissioned drive that skipped wiping, a cloud volume snapshot, a backup tape. None of your file permissions or service sandboxes matter once an attacker reads the blocks directly. Be precise about the scope, though: this lesson encrypts a secondary data volume while the root filesystem stays unencrypted, so /etc/shadow, the SSH host keys, the systemd credential key and the logs are still readable from a stolen root disk. Encrypting the root filesystem is an install-time choice. In a cloud the provider usually encrypts volumes at rest already; LUKS adds protection against a stolen snapshot only if its key is outside that cloud account's reach. On Linux the standard answer is LUKS, and boot integrity (Secure Boot, kernel lockdown, signed modules) is the matching control for tampering with the code that runs before anything is unlocked. In this lesson you build a LUKS2 volume, add and remove keys, arrange for it to unlock at boot, and read the Secure Boot and lockdown state from the machine rather than assuming it. Everything runs against a file, so no real disk is touched.

How LUKS protects data

LUKS (Linux Unified Key Setup) encrypts a whole block device. The important design point is that your passphrase never encrypts the data directly. The data is encrypted with a single random volume key that is generated once and never changes. That volume key is itself encrypted and stored in one of several keyslots, each unlocked by a different passphrase or key. Type a passphrase, LUKS uses it to decrypt the volume key from a slot, and the volume key decrypts the data.

Two layers of keys
Keyslots (unlock credentials)
slot: passphrase
what a person types
slot: recovery key
high-entropy escrow
slot: TPM2 / key file
unattended unlock
One volume key
random, set at format
never leaves the header
encrypts every block
aes-xts-plain64
unchanged by rotation
so rekeying is cheap
Each keyslot wraps the same volume key. Adding or removing a slot changes who can unlock; it does not re-encrypt the disk.

This is why rotating a passphrase is instant: you add a slot with the new passphrase and remove the old slot, and the terabytes of data underneath are untouched. It is also why losing every keyslot means losing the data outright. There is no reset.

Build a LUKS2 volume

Work on a file with a loop device instead of a real partition. cryptsetup luksFormat writes a fresh LUKS2 header and its first keyslot. The passphrase here comes from a file so the lab is unattended; on a real system you type it. Keep any such key file tight, mode 600 and owned by you, or cryptsetup and systemd will warn.

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -m 700 hard-diskcrypt printf '%s' 'lab passphrase one' > hard-diskcrypt/pass1 chmod 600 hard-diskcrypt/pass1 ls -l hard-diskcrypt
total 4 …
$ sudo truncate -s 128M /var/tmp/hard-diskcrypt.img sudo cryptsetup luksFormat --key-file hard-diskcrypt/pass1 /var/tmp/hard-diskcrypt.img

luksDump reads the header back. The default on cryptsetup 2.8 is LUKS2 with the argon2id key-derivation function, the current recommendation because it is deliberately slow and memory-hard: the header records a time cost and a memory cost that an attacker must spend on every guess. Here the dump shows Time cost: 29 and Memory: 257170 (KiB, about 250 MiB). These are not fixed defaults: cryptsetup benchmarks the machine when it creates a keyslot and picks costs that make one unlock take about two seconds there, and a benchmarked memory cost always lies between 64 MiB and 1 GiB (cryptsetup-luksFormat(8)). The result depends on the machine and on how busy it is at that moment, so each keyslot records its own values; slot 2 below, added later on the same busy lab VM, got 184274 KiB. That memory hardness is what makes a stolen header expensive to brute-force.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo cryptsetup luksDump /var/tmp/hard-diskcrypt.img
LUKS header information Version: 2 … Keyslots: 0: luks2 … PBKDF: argon2id Time cost: 29 Memory: 257170 Threads: 2 …

open decrypts the volume key with the passphrase and creates a decrypted view of the device under /dev/mapper. From there it is an ordinary block device: put a filesystem on it, mount it, use it. cryptsetup status confirms the cipher and that the key lives in the kernel keyring.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo cryptsetup open --key-file hard-diskcrypt/pass1 /var/tmp/hard-diskcrypt.img hard_diskcrypt lsblk -o NAME,TYPE,SIZE,MOUNTPOINTS /dev/mapper/hard_diskcrypt
NAME TYPE SIZE MOUNTPOINTS hard_diskcrypt crypt 112M
$ sudo cryptsetup status hard_diskcrypt
/dev/mapper/hard_diskcrypt is active. type: LUKS2 cipher: aes-xts-plain64 keysize: 512 [bits] key location: keyring …

The encryption is transparent above /dev/mapper and total below it. Write a recognisable string through the mapper, then search for it: it is there in the decrypted view and absent from the backing file, where only ciphertext lives.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo mkfs.ext4 -q /dev/mapper/hard_diskcrypt sudo mkdir /mnt/hard-diskcrypt sudo mount /dev/mapper/hard_diskcrypt /mnt/hard-diskcrypt echo 'payroll export 2026-09' | sudo tee /mnt/hard-diskcrypt/notes.txt sync
payroll export 2026-09
$ sudo grep -a -c 'payroll export' /dev/mapper/hard_diskcrypt sudo grep -a -c 'payroll export' /var/tmp/hard-diskcrypt.img
1 0

grep finds one match through the mapping and none in the raw image (its exit status 1 means "no match", which is the point). Closing the mapping removes the decrypted view; the data is inaccessible again until the next open.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo umount /mnt/hard-diskcrypt sudo cryptsetup close hard_diskcrypt sudo cryptsetup status hard_diskcrypt
/dev/mapper/hard_diskcrypt is inactive.

Keyslots: recovery keys and rotation

A single passphrase is a single point of failure. Add a second credential so a forgotten passphrase is not a lost disk. systemd-cryptenroll --recovery-key generates a high-entropy key, prints it once, and stores it in a new slot. Write it down offline; it is never shown again.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-cryptenroll --unlock-key-file=hard-diskcrypt/pass1 --recovery-key /var/tmp/hard-diskcrypt.img
… liltlvln-clvegdki-hbtrklij-dthjlbil-ndifuref-fdvjbbdb-hbcdifbu-rllnfkuj Please save this secret recovery key at a secure location. It may be used to … New recovery key enrolled as key slot 1.
$ sudo systemd-cryptenroll /var/tmp/hard-diskcrypt.img
SLOT TYPE 0 password 1 recovery

Rotating a passphrase is add-then-remove. luksAddKey puts the new passphrase in a free slot, then luksKillSlot destroys the old one. Do it in that order, and verify the new one works before killing the old, so a typo never locks you out.

deploy@web01 · Ubuntu 26.04 LTS
$ printf '%s' 'lab passphrase two' > hard-diskcrypt/pass2 chmod 600 hard-diskcrypt/pass2 sudo cryptsetup luksAddKey --key-file hard-diskcrypt/pass1 /var/tmp/hard-diskcrypt.img hard-diskcrypt/pass2 sudo cryptsetup luksKillSlot --key-file hard-diskcrypt/pass2 /var/tmp/hard-diskcrypt.img 0 sudo systemd-cryptenroll /var/tmp/hard-diskcrypt.img
SLOT TYPE 1 recovery 2 password

The slot list shows the result: slot 0, the old passphrase, is gone, and slots 1 (recovery) and 2 (the new passphrase) remain. The old passphrase is now refused, and the new one works. cryptsetup returns exit status 2 for a passphrase that matches no slot.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo cryptsetup open --test-passphrase --key-file hard-diskcrypt/pass1 /var/tmp/hard-diskcrypt.img
No key available with this passphrase.
$ sudo cryptsetup open --test-passphrase --key-file hard-diskcrypt/pass2 /var/tmp/hard-diskcrypt.img && echo "pass2 unlocks the volume"
pass2 unlocks the volume
A header backup is a second copy of the lock
cryptsetup luksHeaderBackup saves the header and keyslots to a file, which is worth having because a corrupted header makes the data unrecoverable. But that backup plus a passphrase valid at backup time can decrypt the data even after you change or remove that passphrase on the disk. Store header backups as carefully as the disk itself, and remember that wiping the on-disk header no longer guarantees the data is unreadable if a backup survives.

Unlocking at boot

A server reboots unattended, so something must supply a key without a person at the console. The options trade convenience against where the trust sits. A key file on the root filesystem (referenced from /etc/crypttab) unlocks a secondary volume automatically, but it only protects that volume against theft of the volume alone, since the key travels with the root disk.

A TPM (a chip on the mainboard) can hold the key instead, but only the options decide what it checks. systemd-cryptenroll --tpm2-device=auto on its own binds the key to no boot measurements at all; systemd-cryptenroll(1) states that no PCRs is the default. The TPM then releases the key to anything that boots on that board, a USB stick included, and protects only against the disk leaving the machine. Add --tpm2-pcrs=7 (plus 14 when shim's Machine Owner Keys are used), as the man page suggests, to bind the key to the Secure Boot state and certificates. The cost is operational: PCR 7 changes when the firmware's certificate lists change, a Secure Boot dbx update included, and a headless server then stops at a passphrase prompt. So enrol a recovery key and make sure you have console access before you enrol a TPM, and treat firmware and bootloader updates as a re-enrolment event.

On RHEL the Clevis/Tang model (NBDE, network-bound disk encryption) releases the key only when the machine can reach a Tang server on the trusted network. If that server is down, no host bound to it can boot unattended, so run at least two Tang servers and bind with Clevis's sss (secret sharing) policy. This lab VM has no TPM, so these bindings are described, not demonstrated.

deploy@web01 · Ubuntu 26.04 LTS
$ systemd-cryptenroll --tpm2-device=list
TPM2 support is not installed.
$ ls /sys/class/tpm

The key-file case is worth doing because it shows the mechanism and its cost. Enrol a random key file, store it root-owned and mode 400 in /etc/cryptsetup-keys.d, and add a crypttab line. A 512-byte random key has all the strength it needs in the key itself, so its slot does not need argon2id's memory cost, which would spend seconds of CPU and hundreds of MiB of memory at every boot (and can fail in a small initramfs). --pbkdf pbkdf2 --pbkdf-force-iterations 1000 gives it a cheap slot; systemd-cryptenroll made the same choice for the recovery key (slot 1 shows pbkdf2 with 1000 iterations in the dump). Mark the slot prefer with cryptsetup config as well: cryptsetup-config(8) says preferred slots are tried before normal ones. Without that, an unlock can try an argon2id slot first and pay its full cost before it reaches the cheap one. noauto keeps the line out of the boot sequence for the lab; a real secondary volume omits it so systemd unlocks it at boot.

/etc/crypttab
# name backing device key file options
hard_diskcrypt /var/tmp/hard-diskcrypt.img /etc/cryptsetup-keys.d/hard_diskcrypt.key luks,noauto
deploy@web01 · Ubuntu 26.04 LTS
$ sudo install -d -m 700 /etc/cryptsetup-keys.d sudo dd if=/dev/urandom of=/etc/cryptsetup-keys.d/hard_diskcrypt.key bs=512 count=1 status=none sudo chmod 400 /etc/cryptsetup-keys.d/hard_diskcrypt.key sudo cryptsetup luksAddKey --key-slot 0 --pbkdf pbkdf2 --pbkdf-force-iterations 1000 --key-file hard-diskcrypt/pass2 /var/tmp/hard-diskcrypt.img /etc/cryptsetup-keys.d/hard_diskcrypt.key sudo ls -l /etc/cryptsetup-keys.d/hard_diskcrypt.key
…
$ sudo cryptsetup config --key-slot 0 --priority prefer /var/tmp/hard-diskcrypt.img sudo cryptsetup luksDump /var/tmp/hard-diskcrypt.img | grep -A7 '^ 0: luks2'
0: luks2 Key: 512 bits Priority: preferred Cipher: aes-xts-plain64 Cipher key: 512 bits PBKDF: pbkdf2 Hash: sha256 Iterations: 1000
$ echo 'hard_diskcrypt /var/tmp/hard-diskcrypt.img /etc/cryptsetup-keys.d/hard_diskcrypt.key luks,noauto' | sudo tee -a /etc/crypttab sudo systemctl daemon-reload sudo systemctl start systemd-cryptsetup@hard_diskcrypt.service lsblk -o NAME,TYPE /dev/mapper/hard_diskcrypt
… NAME TYPE hard_diskcrypt crypt

systemd turns each crypttab line into a systemd-cryptsetup@NAME service. Starting it unlocks the volume exactly as boot would; with the cheap, preferred slot the status shows CPU: 231ms and Mem peak: 2.4M for the whole unlock.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl status systemd-cryptsetup@hard_diskcrypt.service
● systemd-cryptsetup@hard_diskcrypt.service - Cryptography Setup for hard_diskcrypt Loaded: loaded (/etc/crypttab; generated) Active: active (exited) since Sun 2026-09-27 10:18:19 UTC; 37ms ago … Mem peak: 2.4M CPU: 231ms … Sep 27 10:18:19 web01 systemd[1]: Starting systemd-cryptsetup@hard_diskcrypt.service - Cryptography Setup for hard_diskcrypt... Sep 27 10:18:19 web01 systemd-cryptsetup[407318]: Set cipher aes, mode xts-plain64, key size 512 bits for device /var/tmp/hard-diskcrypt.img. Sep 27 10:18:19 web01 systemd[1]: Finished systemd-cryptsetup@hard_diskcrypt.service - Cryptography Setup for hard_diskcrypt.

To roll the setup back, stop the unit (which closes the mapping), delete the crypttab line and the key file, and reload systemd so the generated unit disappears. Remove the key file's slot as well with cryptsetup luksKillSlot if the volume stays in use. (An image in /var/tmp, as here, is also removed by systemd-tmpfiles after 30 days unused, so a real volume never belongs there.)

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl stop systemd-cryptsetup@hard_diskcrypt.service ls /dev/mapper
control
$ sudo sed -i '/^hard_diskcrypt /d' /etc/crypttab sudo rm /etc/cryptsetup-keys.d/hard_diskcrypt.key sudo systemctl daemon-reload systemctl list-units --all --no-legend 'systemd-cryptsetup@hard*'

The operational reality is the point of this section. A LUKS volume that needs a typed passphrase cannot come back from a reboot on its own, so a headless server needs the TPM, Tang or key-file path, each of which weakens the stolen-disk protection in some way. And key loss is data loss: no keyslot, no recovery key, no header backup means the volume is gone. Plan the recovery path before you encrypt anything you cannot afford to lose.

Boot integrity: read it, do not assume it

Encryption protects data at rest; it does nothing about tampering with the code that runs before the disk is unlocked. Two kernel features raise the cost of that. Secure Boot checks that the bootloader and kernel carry a trusted signature, and kernel lockdown then restricts even root from routes that would read or rewrite the running kernel (/dev/mem, unsigned modules, kexec). Both must be read from the machine, because their state depends on the firmware, not on the distribution.

deploy@web01 · Ubuntu 26.04 LTS
$ mokutil --sb-state
This system doesn't support Secure Boot
$ cat /sys/kernel/security/lockdown
[none] integrity confidentiality

On this VM the firmware reports no Secure Boot, and /sys/kernel/security/lockdown shows [none]: lockdown is compiled in but not engaged, so nothing above is enforced yet. On a physical server with Secure Boot on, lockdown moves to integrity automatically. mokutil --sb-state (with the shim bootloader that Ubuntu and RHEL use for Secure Boot) reports the state and manages the Machine Owner Keys that let you trust modules you build yourself.

Module signing is the related control the modules lesson builds on. Ubuntu's kernel is built with CONFIG_MODULE_SIG=y and its modules are signed, but MODULE_SIG_FORCE is not set, so an unsigned module still loads unless enforcement is switched on: by lockdown (engaged through Secure Boot) or by module.sig_enforce=1 on the kernel command line (the kernel's module-signing documentation). You can confirm a shipped module is signed:

deploy@web01 · Ubuntu 26.04 LTS
$ grep -E 'CONFIG_MODULE_SIG(_FORCE)?[= ]|CONFIG_LOCK_DOWN_IN_(EFI_)?SECURE_BOOT' /boot/config-$(uname -r)
CONFIG_MODULE_SIG=y # CONFIG_MODULE_SIG_FORCE is not set …
$ modinfo sctp | grep -E '^(filename|signer|sig_hashalgo):'
filename: /lib/modules/7.0.0-34-generic/kernel/net/sctp/sctp.ko.zst signer: Build time autogenerated kernel key sig_hashalgo: sha512

So the accurate summary for a default cloud image is: signing infrastructure present, enforcement off until Secure Boot engages lockdown or the kernel command line sets module.sig_enforce=1. Turning that on is a firmware and bootloader exercise beyond one host's config file, but knowing how to read the state is the first step, and it is what an auditor asks for.

On RHEL 10

cryptsetup and systemd-cryptenroll work the same way, and LUKS2 with argon2id is the same default. The differences are in unattended unlock and packaging: RHEL's preferred network unlock is Clevis with Tang, which binds a keyslot to a policy such as "this Tang server is reachable", so a disk stolen off the network stays locked. On Rocky 10.2 the packages come from these repositories:

deploy@rocky10 · Rocky Linux 10.2
$ timeout 180 dnf -q info cryptsetup clevis clevis-luks clevis-dracut tang </dev/null | grep -E '^(Name|Version|Repository|From repo) '
Name : clevis Version : 21 Repository : appstream Name : clevis-dracut Version : 21 Repository : appstream Name : clevis-luks Version : 21 Repository : appstream Name : cryptsetup Version : 2.8.1 Repository : baseos Name : tang Version : 14 Repository : appstream

TPM2 enrollment through systemd-cryptenroll is available on both. Secure Boot and lockdown are read exactly as above; mokutil is packaged on RHEL too.

Try this

On a lab machine, create a 128 MiB file, sudo cryptsetup luksFormat it with a passphrase (interactively it asks you to type YES in capitals, then the passphrase twice), and luksDump it to find the argon2id line and its memory cost. Open it, put an ext4 filesystem on it, mount it, write a file, then unmount and cryptsetup close. Prove the encryption two ways: sudo grep -a for your text finds it through /dev/mapper and not in the backing file. Then add a recovery key with systemd-cryptenroll --recovery-key, list slots with systemd-cryptenroll <file>, and confirm both the passphrase and the recovery key open the volume. Finish by reading /sys/kernel/security/lockdown and mokutil --sb-state on the same machine and noting what each reports.

Takeaway

LUKS encrypts data with a volume key that many keyslots can unlock, so add a recovery key, rotate by add-then-remove, and treat header backups as copies of the lock. Unattended unlock (TPM2, Tang, or a key file) always trades some of the stolen-disk protection for the ability to reboot headless, and key loss is data loss. Read Secure Boot and lockdown state from the machine; do not assume it.

Quick check
01You change the passphrase on a 2 TB LUKS volume and it completes in under a second. A colleague worries the data was not really re-encrypted. What actually happened?
Incorrect — There is no separate overnight rekey. The data was never tied to the passphrase, so nothing about the data protection changed or needs to.
Incorrect — A reboot is not involved. The new slot is on disk immediately and the old one is gone once you kill it.
Incorrect — Re-encrypting 2 TB is never sub-second even with acceleration; the volume key does not change on a passphrase rotation.
Correct — Passphrases unlock the volume key; rotating one rewrites a keyslot, not the terabytes the volume key encrypts, so it is instant.
02A headless server has an encrypted secondary data volume. You want it to mount automatically after a reboot with no one at the console. Which choice keeps the most protection against a disk stolen on its own?
Correct — A PCR-bound TPM slot or a Tang binding releases the key only in the trusted environment, so a disk removed and read elsewhere stays locked. Without --tpm2-pcrs the TPM binds to no measurements.
Incorrect — This unlocks automatically but the key rides with the root disk, so stealing both disks (or a full image) defeats it.
Incorrect — A LUKS volume always needs a keyslot; removing the last one destroys access, and an unkeyed volume is just unencrypted.
Incorrect — The journal is on the same host and readable, so this is a plaintext key on the machine, not a protection.
03On a default Ubuntu 26.04 cloud image, mokutil --sb-state reports no Secure Boot and /sys/kernel/security/lockdown shows [none]. What does that tell you about loading an unsigned kernel module?
Incorrect — Signing is enabled (CONFIG_MODULE_SIG=y), but that only signs Ubuntu's own modules; without enforcement an unsigned one still loads.
Correct — MODULE_SIG_FORCE is not set and lockdown is [none], so an unsigned module loads; Secure Boot moves lockdown to integrity, or the kernel parameter module.sig_enforce=1 enforces signatures directly.
Incorrect — It can be blocked: lockdown via Secure Boot, module.sig_enforce=1 on the kernel command line, or MODULE_SIG_FORCE in a custom build all enforce signatures.
Incorrect — Being compiled in is not the same as engaged; [none] means it is not enforcing anything.

Related