Chris Collins

Article · CryptoFS

Transparent file encryption in the Linux kernel

How I built CryptoFS, a stacked Linux filesystem that encrypts every file with its own AES-256-GCM key at the VFS layer — authorised processes see plaintext, everyone else sees ciphertext — and what it taught me about nonces.

· 7 min read · source

Software engineering

Most of the encryption I deal with at work happens a long way from the disk: TLS on the wire, envelope encryption in an application, a cloud provider’s "encryption at rest" checkbox. Products like Thales CipherTrust Transparent Encryption take a different position. They sit in the operating system, encrypt files as they are written, and decide per process who gets to see plaintext. The database, the search engine, the log shipper — none of them change a line of code.

I wanted to understand how that actually works, so I built a small version of it: CryptoFS, a Linux kernel module plus a Rust key daemon and admin CLI. This is the tour.

The idea: a filesystem on top of a filesystem

CryptoFS is a stacked filesystem, in the same family as eCryptfs and the classic wrapfs teaching example. It registers a filesystem type, cryptofs, and mounts one directory over another:

sudo mount -t cryptofs /data/encrypted /mnt/decrypted

The lower directory holds what actually lands on disk. The upper mount is the view applications use. Every VFS operation on the upper side — lookup, create, read, write, rename, mmap — arrives at the module first, which does its work and then calls the corresponding operation on the lower filesystem. Because it stacks on an existing directory, it works over ext4, xfs, or btrfs without reformatting anything.

Diagram of a write passing from an authorised process through the VFS
Figure 1. One write() through CryptoFS: the policy check decides whether data is encrypted on the way down, or the write is refused.

Per-process policy, enforced in the kernel

The interesting part of transparent encryption is not the cipher; it is the decision about who gets plaintext. CryptoFS answers that in policy.c, on every read and write, by looking at the calling task. Rules can match on:

  • UID or GID;

  • the executable’s path, resolved from the task’s mm→exe_file;

  • the executable’s SHA-256 hash, computed in the kernel by reading the binary — so swapping /usr/bin/myapp for something else stops it decrypting;

  • the process name (comm) — convenient, and weak, because a process can set its own name.

An authorised reader gets plaintext. An unauthorised reader gets the raw extents — real ciphertext, header stripped — so a backup agent can be left unauthorised and still read the bytes without ever seeing the clear text (restoring them is another matter, as I explain below). An unauthorised writer gets -EACCES. With no policies configured at all, the module falls back to an allow-all convenience mode, which is what the demos use.

Policies and keys travel from user space over a Generic Netlink family, and the module keeps an audit trail in a kernel ring buffer.

Extents, tags, and an on-disk format

Files are processed in 4 KB extents, the same size as a page. Each extent is encrypted with AES-256-GCM through the kernel Crypto API (gcm(aes)), and its 16-byte authentication tag is stored right after it. A file on the lower filesystem looks like this:

  • a 128-byte header: the magic CRYPTOFS, a version, the file’s wrapped key and wrapping nonce, the plaintext length, and the ID of the master key that wrapped it;

  • then extent after extent of 4096 bytes of ciphertext plus a 16-byte tag.

Mapping a logical offset to the lower file is arithmetic, not a lookup: 128 + (L / 4096) × 4112 + (L % 4096). Random reads decrypt only the extents they touch, and a partial write reads the existing extent, decrypts it, patches it, and encrypts it again. The overhead is 128 bytes per file plus 16 bytes per extent — about 0.4%.

Memory-mapped files go through the page cache: read_folio decrypts one extent into one page, and writeback encrypts it on the way out. Because a page and an extent are both 4 KB, the mapping is one-to-one, which kept that code short.

Every extent’s GCM tag is checked on read, so if anything on the disk is altered — a flipped bit, a spliced block — the read fails loudly instead of returning corrupted data.

Keys: three layers

Diagram: passphrase → Argon2id KEK → master key held by the key daemon and loaded into the kernel key table; per file
Figure 2. The key hierarchy: a passphrase unlocks the master key; the master key wraps a random key per file; each file key encrypts its extents and derives their nonces.
  • Master keys live in user space with cryptofs-keyd, a Rust daemon. On disk each one is encrypted under a key derived from a passphrase with Argon2id. "Unlock" decrypts it into the daemon’s memory; "activate" pushes it into the module’s key table, which holds up to 64 keys by 16-byte ID.

  • File encryption keys are 256 random bits from get_random_bytes, generated when a file is created and stored in its header wrapped by a master key with AES-256-GCM. Losing one file key exposes one file.

  • Extent nonces are not stored at all. Each is derived as HMAC-SHA256(FEK, inode ‖ extent index) truncated to 12 bytes.

There are two ways for the kernel to find a master key. In transparent mode the admin activates keys and every authorised process uses them. In guarded mode a policy binds a user to a key that is never loaded globally: the user runs cryptofs-admin key session-unlock, which puts the key into their session keyring, and at open time the module calls request_key() for cryptofs:<key-id>. If the key is not in that process’s keyring, the open fails with ENOKEY. It is the closest thing in the project to "only Alice can read Alice’s files, even though root mounted the volume" — with the usual caveat that root on a live system can still reach almost anything.

The daemon talks to keys through a KeyProvider trait. Only a local provider exists today; the trait is there so an external KMS — HashiCorp Vault’s transit engine, AWS KMS, or my own SecureVault — can be slotted in later. That integration does not exist yet.

What I would change: the nonce

Writing this up made me look hard at the one decision I am least comfortable with.

Deriving nonces from the file key, inode number, and extent index means nothing needs storing and two extents never share a nonce. But an extent that is rewritten keeps its nonce. If it is rewritten with different data, AES-GCM is used twice with the same key and nonce — the cardinal sin of GCM. An attacker who captures the lower file before and after the rewrite learns the XOR of the two plaintexts for that extent, and nonce reuse also weakens GCM’s authentication. The README’s note that "the same data produces the same ciphertext" is true, but it understates the problem: the risk is different data under the same nonce.

The inode number in the derivation has a second consequence: the ciphertext is bound to the lower file’s inode. Copying the lower files to another filesystem — a backup restored with cp or rsync — gives them new inode numbers, and their extents no longer decrypt.

The fix for both is well understood: store a random nonce per extent (12 more bytes per 4 KB), or keep a write counter per extent and mix it in, or move to a construction designed for this — an XTS-style scheme for confidentiality plus separate integrity, or AES-GCM-SIV, which tolerates nonce reuse. That is the next change I would make, and it is the kind of flaw that only shows up when you are forced to explain your design to someone else.

How it is tested

Kernel code is unforgiving, so most of the repository is tests: shell suites for basic file operations, random access and extent boundaries, appends, concurrency, UID policies, guarded keys, and Docker containers (an authorised UID 1000 container sees plaintext; an unauthorised UID 65534 one sees ciphertext). A stress test runs graduated file sizes up to 500 MB, patterned and random data, overwrites, random-offset patches, concurrent workers, file churn, and unmount/remount persistence, checking everything with SHA-256. fio benchmarks compare the encrypted mount with a plain baseline, and build_and_test.sh runs the whole pipeline end to end.

There is also a demo of Apache Solr 9 running in Docker with its index on a CryptoFS mount — no changes to Solr at all, which is the whole point of doing this in the kernel.

Trying it — in a VM

Do not load an experimental kernel module on a machine you care about. On a Mac, the repository includes a Vagrant definition for an ARM64 Ubuntu 24.04 VM under QEMU with every dependency installed:

brew install vagrant qemu && vagrant plugin install vagrant-qemu
make vm-up && make vm-ssh

# inside the VM
make all
sudo insmod kernel/cryptofs.ko
sudo daemon/target/release/cryptofs-keyd --foreground &
cryptofs-admin key generate --label demo     # then key unlock / key activate <id>
mkdir -p /tmp/lower /tmp/mnt && sudo mount -t cryptofs /tmp/lower /tmp/mnt
echo "secret data" > /tmp/mnt/hello.txt
cat /tmp/mnt/hello.txt      # secret data
xxd /tmp/lower/hello.txt    # CRYPTOFS header, then ciphertext

It needs Linux 6.1 or later with CONFIG_CRYPTO_AES and CONFIG_CRYPTO_GCM.

An honest security note

CryptoFS is a learning project and a working prototype. It has not been audited, the nonce scheme above is a real weakness, and it protects data at rest only: it does nothing against a compromised kernel or root on a running system with keys loaded. If you need this in production, use dm-crypt or LUKS for whole volumes, fscrypt for per-directory encryption built into ext4 and f2fs, or a commercial transparent-encryption product.

What it did give me is a working model of how those products are built: a stacked VFS layer, a policy decision made from the calling task, a key hierarchy that keeps master keys out of the kernel until they are needed, and an on-disk format that can be read at any offset. The source is on GitHub.

#linux#kernel#encryption#vfs#aes-gcm#filesystem#security

Related