dm-crypt and LUKS Disk Encryption

dm-crypt is the Linux kernel’s transparent block-device encryption target, implemented in drivers/md/dm-crypt.c (v6.12 source). It is a device-mapper target that interposes a virtual block device over a real one and encrypts every sector on the way down and decrypts it on the way up — so the filesystem stacked above sees only plaintext and is entirely unaware encryption is happening. Each 512-byte (by default) sector is encrypted independently, using the sector number as the basis for the initialization vector, so identical plaintext at different offsets produces different ciphertext. dm-crypt is the encryption engine; LUKS (Linux Unified Key Setup) is the on-disk key-management format that sits on top of it — a standardized header that stores the encryption parameters and multiple passphrase-wrapped copies of the real volume key, managed by the cryptsetup userspace tool (cryptsetup(8)). The single most important idea is the separation of the two: dm-crypt encrypts sectors with a volume key it holds in kernel memory; LUKS decides how that volume key is stored on disk and unlocked by a human passphrase.

This note owns the block-level, full-disk encryption path. Its filesystem-level sibling, fscrypt Filesystem Encryption, encrypts individual files and directory subtrees from inside the filesystem — a different layer with a different threat model (contrasted in detail below). Both ultimately lean on the same kernel crypto primitives, but they sit at opposite ends of the storage stack.

Mental Model

The right way to think about dm-crypt is as a sector-by-sector cipher pipe between two block devices. A write bio (block-I/O descriptor) arrives addressed to the virtual /dev/mapper/encrypted device; dm-crypt clones it, encrypts each sector’s worth of data into freshly allocated bounce pages, and submits the encrypted clone to the underlying disk. A read does the reverse: it reads ciphertext from the disk, then decrypts it in place before completing the original bio. The cipher, the mode, and the rule for turning a sector number into an initialization vector are all fixed when the target is created; the volume key that actually keys the cipher lives only in kernel memory and never touches the disk in cleartext.

LUKS wraps a key-management layer around this. The disk gets a LUKS header holding the cipher spec and a set of key slots, each of which stores the volume key encrypted under a key derived from one passphrase. To unlock the device, cryptsetup reads the header, runs your passphrase through a password-hardening Key Derivation Function (KDF), uses the result to decrypt one key slot, recovers the volume key, and hands it to the kernel to build the dm-crypt target.

flowchart TB
  subgraph US["Userspace (cryptsetup)"]
    PW["Passphrase"] -->|"Argon2id (LUKS2)<br/>or PBKDF2 (LUKS1)"| DK["Slot key"]
    HDR["LUKS header on disk<br/>(cipher spec + key slots)"] -->|"read AF-split blob"| AF["Encrypted volume key<br/>(anti-forensic stripes)"]
    DK -->|"decrypt + AFmerge"| VK["Volume key<br/>(verified vs digest)"]
  end
  VK -->|"ioctl: build dm target"| DMC["dm-crypt target<br/>(in kernel)"]
  subgraph KERNEL["Kernel block layer"]
    FS["Filesystem (ext4 / xfs)"] -->|"plaintext bio"| DMC
    DMC -->|"per-sector AES-XTS<br/>IV = plain64(sector)"| DISK["Underlying disk<br/>(ciphertext sectors)"]
  end

How LUKS and dm-crypt cooperate. What it shows: the passphrase only ever unlocks the volume key (top half, userspace); the volume key then keys the in-kernel dm-crypt target that encrypts every sector flowing between the filesystem and the disk (bottom half, kernel). The insight to take: changing or revoking a passphrase only rewrites a key slot — it never re-encrypts the disk, because the volume key underneath is unchanged. That is the entire reason LUKS exists.

Mechanical Walk-through — the dm-crypt target

Per-sector encryption and the IV

dm-crypt splits each incoming bio into sectors (default 512 bytes, 1 << SECTOR_SHIFT; configurable from 512 up to 4096 bytes via the sector_size parameter, power-of-two only) and encrypts each one as an independent unit (dm-crypt.c, v6.12). The core loop is crypt_convert(), which walks the input and output bio iterators (ctx->iter_in, ctx->iter_out) one sector at a time, calling crypt_convert_block_skcipher() (for plain symmetric ciphers) or crypt_convert_block_aead() (for authenticated encryption). After each sector it advances the iterators by cc->sector_size and bumps the running sector counter ctx->cc_sector by sector_step = cc->sector_size >> SECTOR_SHIFT.

The reason each sector is encrypted independently — rather than as one long stream — is random access: a filesystem must be able to read or overwrite sector 4096 without touching sector 0. But independent encryption with a fixed key would make identical plaintext sectors encrypt identically, leaking structure (you could see which 512-byte blocks of a disk are equal). The fix is a per-sector Initialization Vector (IV) derived from the sector’s own address. dm-crypt offers ten IV-generation strategies; the one that matters is the default the tooling picks:

  • plain64 — “64-bit little-endian version of the sector number, padded with zeros.” This is cryptsetup’s default IV mode. The sector index is the IV; no per-sector secret state is needed, so it is cheap and stateless.
  • plain — the same but truncated to 32 bits (overflows past ~2 TiB sector offsets, so superseded by plain64).
  • essiv (“Encrypted Salt-Sector IV”) — the sector number is encrypted with a salt-derived key before use as the IV, to prevent watermarking attacks against CBC mode. Needed for the legacy aes-cbc-essiv:sha256 profile, not for XTS.
  • eboiv, elephant — BitLocker-compatibility IVs (encrypted byte-offset IV, and the older “Elephant diffuser”), used by cryptsetup only when opening Windows BitLocker volumes.
  • lmk, tcw, benbi, null, plain64be — compatibility modes for loop-AES, pre-4.1 TrueCrypt, LRW, and obsolete ciphers.

The full cipher specification has the form cipher[:keycount]-chainmode-ivmode[:ivopts] (dm-crypt kernel docs). The default, aes-xts-plain64, parses as: cipher aes, chaining mode xts, IV mode plain64. XTS (XEX-based Tweaked-codebook mode with ciphertext Stealing, the IEEE 1619 standard disk-encryption mode) is itself tweakable — it takes the sector number as a “tweak,” so the IV-mode’s job is just to supply that tweak. XTS is length-preserving (no ciphertext expansion) and uses two AES keys: one for the block cipher and one for the tweak, which is why an “AES-256-XTS” volume needs a 512-bit key (two 256-bit halves) and the default “AES-128-XTS” needs 256 bits (two 128-bit halves). The newer capi: prefix lets you write the kernel crypto API name directly: capi:xts(aes)-plain64, capi:gcm(aes)-random for authenticated GCM, or composite specs like capi:essiv(xts(aes),sha256).

Uncertain

Verify: that plain64 (not essiv) is the current cryptsetup default IV for the default aes-xts-plain64 profile. Reason: the kernel dm-crypt.c registers several IV ops and does not itself impose a default; the default lives in cryptsetup’s compiled config, and configure.ac sets DEFAULT_LUKS1_MODE=xts-plain64 (configure.ac, v2.7.0) which implies plain64, but I did not open the exact line that maps the bare mode string to the IV generator. To resolve: run cryptsetup --help (it prints the compiled-in default cipher) or inspect lib/setup.c. uncertain

Per-CPU crypto and the write-ordering thread

Encryption is CPU-heavy, so dm-crypt is built to parallelize it across cores. The global per-target state lives in struct crypt_config (abbreviated cc), which holds the cipher transforms (a union of crypto_skcipher **tfms or crypto_aead **tfms_aead), two workqueues, and a dedicated write thread:

struct crypt_config {
    struct workqueue_struct *io_queue;     /* read-bio submission */
    struct workqueue_struct *crypt_queue;  /* the actual crypto work */
    struct task_struct *write_thread;      /* serializes write submission */
    struct rb_root write_tree;             /* writes ordered by sector */
    /* ... cipher_tfm union, key, iv_gen_ops, sector_size ... */
};

Per bio, dm-crypt allocates a struct dm_crypt_io carrying the conversion context, a pending-I/O counter, and a work_struct. The flow: an incoming write is queued on crypt_queue (a workqueue, so the crypto runs on a kernel worker thread, spread across CPUs by default); when its sectors are encrypted, the resulting clone is not submitted directly — instead it is inserted into the per-target red-black tree write_tree, keyed by sector, and a single write_thread drains the tree in sector order. This serialized, sorted write submission matters for two reasons: it preserves write ordering for zoned block devices (which demand sequential writes — see Zoned Block Devices and ZBD), and it keeps integrity (AEAD) metadata consistent. Reads take the symmetric path through io_queue and kcryptd_io_read_work(), decrypting after the ciphertext arrives.

Several tuning flags adjust this. same_cpu_crypt (DM_CRYPT_SAME_CPU) pins the crypto to the submitting CPU (using WQ_CPU_INTENSIVE) instead of spreading it — useful to avoid cross-CPU cache traffic on some workloads. no_read_workqueue / no_write_workqueue (added for low-latency NVMe) bypass the workqueue and encrypt inline in the submitting context when it is safe, cutting scheduling latency. To bound memory, dm-crypt caps its bounce-page allocations: a per-cpu counter n_allocated_pages enforces a global limit of roughly 2% of non-highmem RAM per active dm-crypt device.

Authenticated encryption (dm-integrity integration)

By default dm-crypt provides confidentiality only — like fscrypt’s contents encryption, it does not detect tampering, because authentication tags would expand each sector and there is nowhere to store them. The integrity:<tag_size>:aead option changes that: combined with a dm-integrity device underneath (which provides per-sector metadata space), dm-crypt can run an Authenticated Encryption with Associated Data (AEAD) cipher such as capi:gcm(aes)-random or authenc(hmac(sha256),...). The AEAD scatterlist for each sector is laid out as Associated Data (the little-endian sector number plus the IV) followed by the data and then the authentication tag:

|----- AAD -------|------ DATA -------|-- AUTH TAG --|
| sector_LE | IV  | sector in/out     | tag in/out   |

A decryption that fails the tag check returns EBADMSG (errno 74); dm-crypt sets an aead_recheck flag to re-read and re-verify before reporting corruption, and logs the event via dm_audit_log_bio(). With random IVs (only valid with integrity, since the random IV must be stored per sector in the integrity metadata) even identical sectors written twice differ, closing the last structural leak.

LUKS — the on-disk key-management format

dm-crypt alone takes a raw volume key as a hex string on the dmsetup command line and stores nothing on disk. That is fragile: lose the key and the data is gone; there is no way to change the passphrase without re-encrypting; and there is no standard place to record which cipher was used. LUKS solves all three by defining a standardized header.

The volume key and key slots

The cornerstone of LUKS is the volume key (also called the master key): a single, high-entropy random key that actually encrypts the data sectors. The user’s passphrase never keys the disk. Instead, LUKS stores one or more encrypted copies of the volume key in key slots — LUKS1 has exactly 8 slots; LUKS2 supports up to 32 (the reference implementation caps it there; LUKS2_OBJECTS_MAX 32 in luks2.h). Each active slot holds the volume key encrypted under a slot key derived from one passphrase. The “multiple passphrases that can be individually revoked or changed” model the man page describes (cryptsetup(8)) falls straight out of this: adding a passphrase fills a free slot; revoking one wipes a slot; neither touches the volume key or the data.

Passphrase hardening — PBKDF2 vs Argon2id

Passphrases are low-entropy, so LUKS runs them through a deliberately slow, salted password-based KDF before they touch a key slot. LUKS1 supports only PBKDF2 (Password-Based Key Derivation Function 2, PKCS#5), which iterates a hash (default SHA-256 in modern cryptsetup) tens of millions of times to make brute-force guessing expensive. LUKS2 defaults to Argon2id — a memory-hard function that forces an attacker to spend large amounts of RAM per guess, defeating cheap GPU/ASIC parallel cracking that PBKDF2 is vulnerable to (LUKS2 spec §3). The compiled-in LUKS2 defaults are Argon2id with 2000 ms target iteration time, 1 GiB (1048576 kB) memory cost, and up to 4 parallel threads, all auto-tuned by benchmarking the machine at format time (configure.ac, v2.7.0). This is the same kernel-does-derivation / userspace-does-stretching division fscrypt prescribes: the slow, memory-hard work is done in userspace; the kernel only ever sees the final volume key.

Anti-forensic stripes

A subtle problem: when you change a passphrase, the old encrypted volume key must be erased from the slot. But on flash and on log-structured filesystems, overwriting a small region does not reliably destroy the old bytes (wear-leveling may remap them). LUKS counters this with the anti-forensic (AF) splitter invented by Clemens Fruhwirth (LUKS1 spec §2.4). Instead of storing the volume key directly, each slot stores it AFsplit into many stripes — by default 4000 stripes — so the on-disk key material is 4000× the key size. AFsplit works by generating 4000 − 1 random stripes and computing the last so that diffusing all of them together (d_k = H(d_{k-1} ⊕ s_k), with H a hash-based diffusion function) and XORing recovers the key. The security property: to recover the volume key an attacker needs every stripe; if even one of the 4000 stripes is wiped or remapped, the key is gone. So overwriting the slot only needs to destroy a fraction of the inflated blob to be effective. LUKS2 keeps the same af object with "type": "luks1", "stripes": 4000, "hash": "sha256" (LUKS2 spec §3).

Unlocking, step by step

When you cryptsetup open a LUKS device, the sequence is: (1) read the header and pick a slot; (2) run the entered passphrase plus that slot’s salt through the slot’s KDF (Argon2id/PBKDF2) to get the slot key; (3) use the slot key to decrypt the slot’s AF-split blob; (4) AFmerge the 4000 stripes back into a candidate volume key; (5) verify it against the header’s digest — in LUKS2 a pbkdf2-type digest object whose digest field is checked against the recovered key, so a wrong passphrase is rejected rather than silently producing garbage; (6) hand the verified volume key to the kernel to build the dm-crypt target.

LUKS1 vs LUKS2 header

The LUKS1 header (Version 1.2.3 spec) is a single fixed 592-byte binary phdr starting at sector 0: a magic of "LUKS\xba\xbe", a version field, then cipher-name, cipher-mode, hash-spec, payload-offset (where ciphertext begins, in 512-byte sectors), key-bytes, the master-key digest (mk-digest, mk-digest-salt, mk-digest-iter), the partition UUID, and an array of 8 key-slot structures. LUKS2 replaces this with a richer, redundant layout (LUKS2 spec §2): a small 4096-byte binary header (struct luks2_hdr_disk, magic "LUKS\xba\xbe" primary / "SKUL\xba\xbe" secondary, a seqid sequence counter, a csum SHA-256 checksum over header+JSON), a JSON metadata area, and a separate keyslots binary area. The binary+JSON header is written twice (primary and secondary) so a corrupting partition tool that clobbers one copy leaves the other recoverable; the binary header is intentionally LUKS1-compatible at the magic/UUID offsets so blkid still recognizes the device. The JSON metadata has five object types: keyslots (where each key is stored, the AF and KDF parameters), digests (verify recovered keys), segments (the encrypted data extents), tokens (optional external-keystore bindings, e.g. unlock from the kernel keyring or a TPM), and config (header size, flags like allow-discards).

Configuration and Worked Example

LUKS with cryptsetup (the normal path)

# 1. Format a partition as LUKS2 (the compiled-in default since cryptsetup 2.1).
#    Defaults: cipher aes-xts-plain64, key 256 bits (= AES-128-XTS, two 128-bit
#    halves), header hash sha256, KDF argon2id. -y prompts twice for the passphrase.
cryptsetup luksFormat --type luks2 -y /dev/sdb1
 
# 1b. To force AES-256-XTS, ask for a 512-bit key explicitly:
cryptsetup luksFormat --type luks2 --cipher aes-xts-plain64 \
           --key-size 512 --hash sha256 --pbkdf argon2id /dev/sdb1
 
# 2. Open it: derive the slot key, recover the volume key, build the dm-crypt
#    target. Appears as /dev/mapper/secret.
cryptsetup open /dev/sdb1 secret      # prompts for passphrase
 
# 3. Use it like any block device.
mkfs.ext4 /dev/mapper/secret
mount /dev/mapper/secret /mnt/secret
 
# 4. Add a second passphrase (fills a free key slot; does NOT re-encrypt data).
cryptsetup luksAddKey /dev/sdb1
 
# 5. Revoke slot 1 (anti-forensic-wipes that slot's key material).
cryptsetup luksKillSlot /dev/sdb1 1
 
# 6. Inspect: prints version, cipher, KDF, and which slots are populated.
cryptsetup luksDump /dev/sdb1
 
# 7. Lock again: tears down the dm-crypt target, wiping the volume key from RAM.
umount /mnt/secret
cryptsetup close secret
 
# 8. Back up the header SEPARATELY — losing it means losing all data, since the
#    volume key exists ONLY in the header's key slots.
cryptsetup luksHeaderBackup /dev/sdb1 --header-backup-file luks-header.img

The critical line to understand is step 4: adding a key only fills a slot with another wrapped copy of the same volume key. This is why LUKS passphrase changes are instant on a multi-terabyte disk — nothing is re-encrypted.

Raw dm-crypt with dmsetup (no LUKS header)

You can drive dm-crypt directly, bypassing LUKS entirely — this is what cryptsetup’s plain mode does, and it stores no metadata at all:

# Table line: "<start> <sectors> crypt <cipher> <key> <iv_offset> <dev> <offset>"
dmsetup create plain1 --table \
  "0 $(blockdev --getsz /dev/sdb1) crypt aes-xts-plain64 \
   0000000000000000000000000000000000000000000000000000000000000000 \
   0 /dev/sdb1 0"
  • 0 — starting logical sector of the mapping.
  • $(blockdev --getsz ...) — length in 512-byte sectors.
  • crypt — the target type.
  • aes-xts-plain64 — the full cipher spec (cipher-mode-ivmode).
  • the 64 hex chars — the raw volume key (here a 256-bit/AES-128-XTS key; for AES-256-XTS supply 128 hex chars = 512 bits). This is on the command line in plaintext, which is exactly the fragility LUKS exists to fix.
  • 0iv_offset, added to the sector number before IV computation (lets you carve sub-regions).
  • /dev/sdb1 — the backing device.
  • final 0 — the data start offset on the backing device, in sectors.

Optional parameters append after a count: dmsetup create ... --table "... crypt ... 0 /dev/sdb1 0 1 allow_discards" enables TRIM passthrough. Others include same_cpu_crypt, submit_from_crypt_cpus, no_read_workqueue, no_write_workqueue, sector_size:4096, and integrity:32:aead.

dm-crypt versus fscrypt — where encryption lives in the stack

This is the contrast that defines dm-crypt’s place. fscrypt Filesystem Encryption and dm-crypt solve the same problem (encryption at rest) at opposite layers, and the differences are not cosmetic — they are different threat models.

  • dm-crypt encrypts the whole device; fscrypt encrypts files. dm-crypt sits below the filesystem and encrypts every sector — file contents, and all metadata: file sizes, names, timestamps, permissions, the directory tree, free-space layout, even the filesystem superblock. An attacker who steals the powered-off disk learns nothing — not even how many files exist. fscrypt sits inside the filesystem and encrypts only file contents and filenames; sizes, timestamps, permissions, xattrs, and hole layout are left in the clear (this is why Android layers a dm-default-key block device under fscrypt to plug the metadata gap).
  • One key domain vs many. dm-crypt has a single volume key for the entire device — you cannot have some files encrypted and others not, nor give two users cryptographically isolated data on a shared volume. fscrypt derives a per-file key from a master key plus a per-inode nonce, so different subtrees can use different master keys and unencrypted files can coexist — exactly the multi-user/per-user-lock model Android File-Based Encryption needs.
  • No per-file granularity, and you must format around it. With dm-crypt you cannot lock one user’s home directory while another’s stays open; the whole device is unlocked or not. ext4 even refuses to apply an fscrypt policy to a root directory — the docs explicitly say “to encrypt an entire volume with one key, use dm-crypt instead” (fscrypt.rst, v6.12).
  • They compose. Because dm-crypt is just a block device that eats and emits bios, you can stack fscrypt on top of a dm-crypt volume for defense in depth, or run dm-crypt over md RAID over physical disks. See the block-layer stacking story.

Choose dm-crypt/LUKS for laptops, single-tenant disks, and anywhere you need all metadata hidden under one key — the classic full-disk-encryption profile. Choose fscrypt for per-user or per-subtree at-rest confidentiality with filename hiding, transparent to applications, where you can lock one subtree by yanking a key — the mobile-device and multi-user-server profile.

Failure Modes and Gotchas

  • Lose the LUKS header, lose everything. The volume key exists only inside the header’s key slots. A partition tool, a dd over sector 0, or wipefs that clobbers the header destroys the only copies of the key — the data is then cryptographically unrecoverable even with the passphrase. Always luksHeaderBackup. LUKS2’s redundant secondary header mitigates accidental single-copy corruption but not a full wipe.
  • No key available with this passphrase means the passphrase matched no slot’s digest (or you are pointing at the wrong device). Not a hardware error.
  • TRIM/discard leaks structure. allow_discards lets the encrypted device pass TRIM to the SSD, which reveals which sectors are unused (and thus a rough used-space map) and can undermine plausible-deniability setups. It is off by default for this reason; enable only when the SSD-longevity benefit outweighs the leak.
  • Suspend-to-RAM keeps the key in memory. The volume key sits in kernel RAM while the device is open; a cold-boot or DMA attack on a suspended (not powered-off) laptop can recover it. cryptsetup luksSuspend flushes the key from memory for exactly this scenario.
  • The hibernation image is plaintext-adjacent. If swap is not also encrypted, hibernating may write decrypted data (and possibly the key) to an unencrypted swap partition. Encrypt swap too.
  • Wrong cipher/IV on dmsetup create silently produces garbage. Raw dm-crypt does no verification (no digest, unlike LUKS) — a one-character key typo just decrypts to noise with no error. This is the entire reason to use LUKS rather than raw dmsetup for real data.

Alternatives and When to Choose Them

  • fscrypt — file-level, per-user keys, filename encryption; choose when you need per-subtree isolation rather than whole-device. Different threat model (metadata exposed).
  • Plain dm-crypt (no LUKS). No header, no metadata, nothing on disk identifies it as encrypted — useful for plausible deniability or steganographic layouts, but you must remember the exact cipher, key, and offset yourself, and there is no passphrase-change story. LUKS is strictly more convenient for everything else.
  • VeraCrypt / TrueCrypt. Cross-platform containers with hidden-volume deniability; cryptsetup can even open TrueCrypt and BitLocker volumes (via the tcw/eboiv/elephant IV modes) but LUKS is the native Linux choice.
  • Self-encrypting drives (SED / OPAL). The disk’s own controller encrypts; the OS just supplies a key. Zero CPU cost, but you must trust opaque firmware — several SED implementations have shipped broken. dm-crypt’s software path is auditable.
  • Application-level encryption (a database encrypting its own files). Maximum control, end-to-end, but every app reinvents key management; dm-crypt is transparent for all data on the device.

Production Notes

dm-crypt/LUKS is the default full-disk-encryption mechanism on essentially every mainstream Linux distribution’s installer (Ubuntu, Fedora, Debian, RHEL all wire it up through cryptsetup). With AES-NI hardware acceleration the per-sector AES-XTS cost is small enough that encrypted NVMe SSDs run near line rate; on CPUs without AES instructions the software path is the bottleneck and the per-CPU crypt_queue parallelism (and no_*_workqueue flags) becomes the lever. The unlock-at-boot story in production is usually automated: LUKS2 tokens can bind the volume key to a TPM 2.0 (via systemd-cryptenroll --tpm2-device=auto) so the disk unlocks only if the measured boot state is trusted, or to a FIDO2 key, removing the interactive passphrase while keeping the volume-key-in-a-slot model intact. The 2025 Trail of Bits analysis of LUKS2 under confidential VMs is a reminder that the header is unauthenticated against an active attacker with physical write access (the spec says so explicitly — checksums catch random corruption, not deliberate tampering), so for the strongest threat models a measured/remote-attested header is needed.

See Also