- Rust 88%
- Nix 12%
| nix | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE.md | ||
| README.md | ||
| SECURITY.md | ||
luks-and
Derive a LUKS key that requires both a keyfile and a passphrase; a true AND of two factors, not the OR you get from adding two LUKS keyslots.
A normal LUKS setup with a keyfile and a passphrase enrolled as separate
keyslots opens with either one. luks-and instead derives a single key from
both inputs together, so a keyslot enrolled with that key can only be opened when
you present both. Losing one factor to an attacker is not enough.
Status: unaudited. This is a small, self-contained tool built around the RustCrypto
argon2implementation as my first security tool. The construction is deliberately simple and the code is meant to be read end to end before you trust it with a disk. Review it yourself; file issues. No warranty.
How it works
pepper = BLAKE2b-512("luks-and/pepper/v0.1" ‖ len-prefixed keyfile)[..32]
key = Argon2id(password = passphrase,
salt = 16 random bytes (stored in the descriptor),
secret = pepper, // Argon2 "secret" input
m, t, p, key_len)
tag = BLAKE2b-512("luks-and/tag/v0.1" ‖ len-prefixed key)[..16]
- The passphrase is the Argon2 password, so it is memory-hard stretched. If your keyfile leaks, the passphrase is still behind full Argon2: not cheap to bruteforce.
- The keyfile is bound in as the Argon2 secret. If your passphrase leaks, it is useless without the keyfile bytes.
- A random per-descriptor salt makes every enrolled key independent, so the same keyfile+passphrase yields unrelated keys on different volumes.
The descriptor is a small non-secret text file holding the Argon2
parameters, the salt, and a 128-bit tag (a fingerprint of the derived key).
luks-and writes it at enrollment and reads it at unlock, so the two
derivations are identical by construction and the tool can tell you before
calling cryptsetup whether your inputs reproduce the enrolled key. Store the
descriptor with the LUKS header, never with the keyfile.
Install
Requires Rust 1.85+ (edition 2024).
$ cargo build --release
$ ./target/release/luks-and selftest # verify the build computes the canonical scheme
$ cargo test # 40+ tests, including a pinned derivation vector
For older toolchains, pin the two transitive crates that otherwise require
edition 2024: add base64ct = "=1.6.0" and
zeroize = { version = "=1.7.0", default-features = false, features = ["alloc"] }
to [dependencies] and build with edition = "2021" (see
Cargo.toml.validation-edition2021).
Usage
luks-and enroll --keyfile P --descriptor P [--output P|-]
[--memory 1GiB] [--time 4] [--parallelism 1] [--key-length 64]
[--insecure-no-mlock]
luks-and derive --keyfile P --descriptor P [--output P|-] [--insecure-no-mlock]
luks-and inspect --descriptor P
luks-and selftest
The passphrase is always read from stdin; the derived key is written raw to
stdout (or --output FILE, mode 0600). Exit codes: 0 ok, 2 usage error,
3 wrong secrets (retryable — usually a typo), 1 anything else.
Enroll a keyslot
Pick a keyfile (16+ bytes of random data; 32–64 recommended) and store it on separate, offline media:
$ head -c 64 /dev/urandom > /path/to/keyfile.bin # keep this OFF the encrypted machine
# Derive a key from both factors, write the descriptor, hand the key to cryptsetup.
$ systemd-ask-password "new passphrase:" \
| luks-and enroll --keyfile /path/to/keyfile.bin \
--descriptor /boot/efi/luks-and.desc \
--output /run/luks-and.key
$ cryptsetup luksAddKey /dev/sdX /run/luks-and.key # add to an existing volume
$ shred -u /run/luks-and.key
Tune --memory for the machine that will unlock (that RAM must be free in
early boot). --memory 1GiB --time 4 is a strong default; lower it for
constrained devices. A recovery path can afford to be slow.
Unlock (e.g. from an initramfs)
$ systemd-ask-password "passphrase:" \
| luks-and derive --keyfile /mnt/usb/keyfile.bin --descriptor /boot/efi/luks-and.desc \
| cryptsetup open /dev/sdX root --key-file=-
Works the same with a detached header (--header hdr.img) and with
--test-passphrase for a dry run.
Inspect a descriptor
$ luks-and inspect --descriptor /boot/efi/luks-and.desc
Prints the (non-secret) parameters and salt/tag. Useful for debugging a failed unlock — a param mismatch means the descriptor doesn't match how the slot was enrolled.
The descriptor file
luks-and-descriptor
version = 0.1
kdf = argon2id
m_cost_kib = 1048576
t_cost = 4
p_cost = 1
key_len = 64
salt = 7507ca503e05010e96229026be8ec7c2
tag = 288a20896b5c48eadcc0a51d1b4c1d79
Non-secret, but integrity-relevant: editing it changes the derived key, so a tampered descriptor causes a failed unlock (the tag check catches it), never a silent weakening. Back it up with your header; you cannot unlock without it.
Security properties and limits
Holds:
- Both factors are required. Either one alone derives the wrong key; the tag check refuses it (exit 3) and no key is emitted.
- A leaked passphrase is worthless without the keyfile; a leaked keyfile leaves the passphrase behind full Argon2id.
- Secrets live in
mlockall-ed, non-dumpable, non-ptrace-able memory and are zeroized on drop, including the Argon2 memory arena. - The binary runs a known-answer self-test before touching any secret and
refuses to run if it does not compute the canonical scheme (catches a
swapped/mis-compiled
argon2/blake2). - Reading a keyfile/descriptor won't hang on a planted FIFO or follow a planted symlink, and won't splat key bytes into a terminal.
Does not hold / out of scope:
- The
tagis a brute-force oracle for anyone who already has the keyfile (they can test passphrase guesses at the enrolled Argon2 cost). That's why the descriptor must never sit next to the keyfile. It gives no advantage to an attacker who lacks the keyfile. - Secrets are not scrubbed if the process is killed by a signal mid-run (pages are left to kernel zero-on-reuse and kept out of swap by mlockall). Do not signal it mid-derivation.
- This protects a keyslot. It does nothing about the many things around it: where your Secure Boot signing keys live, who can write your EFI system partition, firmware integrity, a machine seized while powered on or suspended, hardware keyloggers, or coercion. Disk encryption is one layer; deploy the rest.
- BLAKE2b's internal state (which briefly holds the keyfile/key) is not individually zeroized; it is overwritten by the Argon2 fill and kept out of swap by mlockall.
Threat model in one line
luks-and makes a single stolen factor useless and lets you know your inputs
are correct before cryptsetup runs. It is the strong, boring core of a system
whose real risks live in deployment. Treat it accordingly.
License
GPLv3