No description
  • Rust 88%
  • Nix 12%
Find a file
2026-08-01 17:52:08 +02:00
nix nix: fix errexit-unsafe retry loop in module.nix, reformat with alejandra 2026-08-01 17:52:08 +02:00
src v0.1 2026-07-04 21:12:13 +02:00
tests v0.1 2026-07-04 21:12:13 +02:00
.gitignore v0.1 2026-07-04 21:12:13 +02:00
Cargo.lock v0.1 2026-07-04 21:12:13 +02:00
Cargo.toml Updated Cargo.toml 2026-07-04 21:28:02 +02:00
LICENSE.md Updated docs 2026-07-05 19:53:17 +02:00
README.md nix: fix errexit-unsafe retry loop in module.nix, reformat with alejandra 2026-08-01 17:52:08 +02:00
SECURITY.md v0.1 2026-07-04 21:12:13 +02:00

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 argon2 implementation 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; 3264 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 tag is 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