Deniable mode
Vaults whose on-disk bytes are indistinguishable from random output. Create, open, mount, enroll.
A deniable LUKSbox vault is a vault whose entire on-disk
representation is computationally indistinguishable from random
output: no magic bytes, no version field, no slot table, no
parseable structure visible to anyone without the right credential.
A forensic analyst staring at the file with file(1), libmagic,
yara, or a hex editor sees uniform random bytes.
This is an opt-in mode chosen at vault-creation time. Standard (non-deniable) vaults are untouched: their plaintext header magic
- version + slot table all stay where they are. Deniable vaults use a separate format (deniable v2, 36 KiB header) and a separate set of CLI subcommands so the two paths cannot be confused.
The full cryptographic design lives at
docs/DENIABLE_HEADER.md
in the source repository. This page is the operator-facing summary.
When you want this
- You need to store a vault somewhere a generic forensic sweep will happen and "owns a LUKSbox vault" is itself sensitive information.
- You want a credential mix (passphrase + FIDO2 + TPM) but do NOT
want a
.lbx.tpmor recovery-card sidecar to advertise which factors a vault uses. - You are willing to remember the cipher choice and Argon2id parameters as part of the secret: forgetting them is permanent lockout, by design.
When you DON'T
- Vaults that need to be opened by colleagues who only know
"double-click the .lbx file". Deniable vaults will not associate
with the LUKSbox file-type because they have no recognisable
bytes; the user has to invoke
luksbox deniable-mount(or use the GUI / TUI deniable-open path) by hand. - Single-user setups where nobody is going to perform a forensic sweep. The standard format already protects the contents under AEAD; the deniable mode hides the existence of the vault on top of that, at the cost of more setup discipline.
- Backup archives indexed by automated tooling that needs to see the magic bytes.
Trade-offs vs the standard format
| Property | Standard vault | Deniable vault |
|---|---|---|
| On-disk magic / version | Plaintext | Random |
File-type detection (file(1), libmagic) |
Identifies as LUKSbox | Identifies as data / random |
| Header size | 8 KiB | 36 KiB |
| Slots | 8, each 512 B | 8, each 4 KiB |
| Pure FIDO2 / pure TPM | Supported | NOT supported (passphrase mandatory, see below) |
| External sidecars | .lbx.tpm, recovery card with cred_id / hmac_salt |
None (everything in-slot) for FIDO2 / TPM; .lbx.kyber retained for ML-KEM |
| Failure modes | WrongPassphrase / WrongDevice / ... |
Single OpaqueUnlockFailed (no oracle) |
| Cipher + Argon2 params | Stored in header | Must be remembered by the user |
Passphrase is mandatory
Every deniable credential variant carries a passphrase as the
envelope factor. The slot envelope is sealed by
KEK_envelope = Argon2id(passphrase, per_vault_salt, params); no
slot can be opened without it. The eight supported variants are:
- Passphrase only
- Passphrase + FIDO2
- Passphrase + TPM
- Passphrase + TPM + FIDO2
- Passphrase + hybrid-PQ (ML-KEM)
- Passphrase + hybrid-PQ + FIDO2
- Passphrase + hybrid-PQ + TPM
- Passphrase + hybrid-PQ + TPM + FIDO2
Pure FIDO2, pure TPM, and pure hybrid-PQ are NOT available in
deniable mode. They would re-introduce the external cred_id /
hmac_salt / TPM-blob sidecars that the v2 design exists to
eliminate. If you want a FIDO2-only vault, use the standard
(non-deniable) format.
The GUI hides the "FIDO2 direct" radio whenever Deniable is ticked in the create dialog for the same reason.
CLI
Create
# Passphrase-only deniable vault, default cipher / Argon2 params.
luksbox deniable-init secret.bin
# Explicit cipher + Argon2 (you MUST remember these to reopen)
luksbox deniable-init secret.bin \
--cipher aes \
--argon2-m 262144 --argon2-t 3 --argon2-p 4 \
--credential passphrase
# Passphrase + FIDO2 (slot embeds cred_id + hmac_salt; no sidecar)
luksbox deniable-init secret.bin --credential fido2
# Passphrase + TPM + FIDO2
luksbox deniable-init secret.bin --credential tpm-fido2
# Hybrid PQ + passphrase (writes a .kyber seed sidecar)
luksbox deniable-init secret.bin \
--credential pq-passphrase \
--kyber-path secret.bin.kyber
# Hybrid PQ-1024 + passphrase + TPM + FIDO2 + anchor sidecar
luksbox deniable-init secret.bin \
--credential pq-tpm-fido2 --pq-1024 \
--kyber-path secret.bin.kyber \
--anchor /run/media/usb/secret.anchor
Supported --credential values: passphrase, fido2,
pq-passphrase, pq-fido2, tpm, tpm-fido2, pq-tpm,
pq-tpm-fido2. Every value is implicitly passphrase-bearing.
Inspect (does it unlock?)
luksbox deniable-info secret.bin \
--cipher aes --argon2-m 262144 --argon2-t 3 --argon2-p 4 \
--credential passphrase
Prints the decrypted inner header (cipher / KDF / flags / offsets) on success. Any failure (wrong passphrase, wrong cipher, wrong Argon2 params, corrupted header) collapses to a single opaque error to preserve the no-oracle property.
Mount
luksbox deniable-mount secret.bin /mnt/v \
--cipher aes --argon2-m 262144 --argon2-t 3 --argon2-p 4 \
--credential passphrase
Same opaque-failure semantics as deniable-info. With an anchor
sidecar:
luksbox deniable-mount secret.bin /mnt/v \
--credential pq-tpm-fido2 --pq-1024 \
--kyber-path secret.bin.kyber \
--anchor /run/media/usb/secret.anchor
A wrong anchor, a missing anchor file, or rollback detection
(anchor_gen > metadata_gen) all fail with the same opaque error.
GUI
In the Create vault dialog, tick Deniable. The form collapses to the variants the deniable format supports (pure FIDO2 / pure TPM disappear; everything left carries a passphrase field). The cipher dropdown and Argon2id sliders stay visible, and the GUI warns you that forgetting these values bricks the vault.
On the Open screen, switch the source to Deniable and the form asks for the cipher / Argon2 params / credential variant the vault was created with. Wrong values produce the same opaque "unlock failed" toast as wrong credentials; the GUI never distinguishes the two.
Anchor errors on the deniable open path
If an anchor sidecar is attached and the GUI cannot read it as a
deniable anchor at all (wrong size, symlink, missing), it now
fails the pre-flight check before asking the format layer to
unlock. A post-credential anchor failure is translated to
"Anchor file does not decrypt under this vault's master key" rather
than the generic "unlock failed". The format layer still returns a
single OpaqueUnlockFailed so an attacker calling the library
directly sees no oracle, but the user-facing GUI gets enough
information to know which file to swap or where to look.
TUI wizard
luksbox wizard includes a Create deniable vault path under
its create menu. Same questions as the GUI: cipher, Argon2id
params, credential variant, optional .kyber seed path for
hybrid-PQ variants, optional anchor path. The wizard refuses to
proceed without a confirmed passphrase (which is mandatory in
deniable mode).
Anchor sidecars (rollback detection)
Standard anchors are 48 bytes with visible AEAD framing; deniable anchors are 256 bytes where every byte is also indistinguishable from random output, so the anchor itself does not reveal that it belongs to a deniable LUKSbox vault. The verification path uses an HKDF-derived subkey of the MVK so a deniable vault and its anchor prove their pairing only when unlocked together.
Both anchor readers (anchor::read_and_verify for 48 B standard
and anchor::deniable_read_and_verify for 256 B deniable) refuse
to dereference symlinks at the format layer (O_NOFOLLOW), so a
swap between the GUI / CLI preflight and the actual open is
rejected with ELOOP.
Hybrid-PQ deniable variants: one or two passphrases
For the four pq-* variants, the ML-KEM seed sidecar
(.lbx.kyber) is wrapped under its own passphrase. At create time
the user picks one of:
- Reuse the envelope passphrase for the seed file. Default behaviour. The GUI and TUI present the seed-file passphrase field as optional with the hint "leave blank to reuse the envelope passphrase above for both roles". One passphrase opens both.
- Set a distinct seed-file passphrase for defence-in-depth. The envelope passphrase opens the slot envelope but the seed file refuses to decrypt without the second passphrase. Both must be supplied at every unlock.
At open time the unlock form shows the envelope passphrase (required) plus a seed-file passphrase field that defaults to blank. Blank means "reuse the envelope passphrase". The same strength meter, "Generate strong passphrase" button, and empty-passphrase confirmation modal apply to both fields.
What forensic analysts can still learn
- The file exists and has a size. The deniable format does not hide that you have a 5 GiB file sitting on disk. If 5 GiB of random-looking bytes is itself suspicious in your context, pair with a steganographic carrier (out of scope for LUKSbox today) or store the file inside a larger container the analyst expects to be opaque (e.g. an encrypted disk image).
- Header size is 36 KiB rather than 8 KiB. The exact size is
not aligned to any well-known multiple, but a sufficiently
motivated analyst with a corpus of suspected vaults could
correlate.
luksbox deniable-init --pad-to <N>quantization remains available for users who want the file size to match a target distribution. .lbx.kyberand.anchorsidecars are not themselves deniable beyond not declaring "LUKSbox" in their bytes. A forensic analyst who sees three randomly-named files together may guess they form a triple even if no individual file is identifiable.
The full enumeration of what is and is not defended lives on the threat model page.
Next
- Security architecture - where the deniable format sits in the trust graph
- Threat model - what deniable mode defends against, and what it explicitly does not
- CLI reference: deniable-init and the matching
deniable-mount/deniable-infoentries