Security architecture
Trust boundaries, on-disk protection graph, unlock flow, and concurrency / crash-safety pipeline, with diagrams.
LUKSbox is a local encrypted-vault tool. It can protect data at rest against an attacker who steals, copies, tampers with, or rolls back vault files. It cannot protect plaintext from malware, a hostile kernel, or a compromised user session after the vault has been unlocked. This page is the architecture map. The concrete attack matrix lives on the threat model page and the primitives on cryptography.
1. Trust boundaries
flowchart LR
User[User knowledge
passphrase or PIN] --> Unlock[Unlock material]
FIDO[FIDO2 authenticator
hmac-secret] --> Unlock
TPM[TPM 2.0
sealed KEK] --> Unlock
SEP[macOS Secure Enclave
non-extractable P-256 key] --> Unlock
PQ[ML-KEM seed file
separate storage] --> Unlock
Unlock --> KEK[Key encryption key]
KEK --> MVK[Master volume key]
MVK --> HeaderMac[Header HMAC key]
MVK --> MetadataKey[Metadata AEAD key]
MVK --> FileKeys[Per-file HKDF keys]
MVK --> AnchorKey[Anchor HMAC key]
HeaderMac --> Header[Authenticated header]
MetadataKey --> Metadata[Encrypted metadata tree]
FileKeys --> Chunks[Encrypted file chunks]
AnchorKey --> Anchor[Rollback anchor]
The Master Volume Key (MVK) is the root secret. Keyslots do not
encrypt file data directly; they wrap the MVK. Once a valid keyslot
unwraps the MVK, LUKSbox derives separate subkeys for header HMAC,
metadata encryption, per-file encryption, and anchor authentication,
all via HKDF-SHA256 with a per-purpose info string.
TPM keyslots talk to the chip through a session salted against its
storage root key (SRK), which defeats a passive bus interposer on a
discrete TPM. Since 0.6.0, new TPM keyslots can also pin the SRK
(opt-in: the global --tpm-pin-srk flag or LUKSBOX_TPM_PIN_SRK=1,
the wizard prompt, or the GUI's "Pin the TPM storage root key"
preference). Unlock then refuses a substituted SRK before anything is
unsealed, which defeats an active interposer. Pinning is
trust-on-first-use (an interposer already present at enroll is not
detected), pinned slots need LUKSbox 0.6.0+ to unlock, and existing
slots are pinned only when re-enrolled or updated. luksbox info
shows which TPM slots are pinned.
2. On-disk protection graph
flowchart TD
VaultFile[".lbx vault file"] --> Header["Header
magic, params, keyslots, HMAC"]
VaultFile --> MetadataRegion["Metadata region
AEAD(file tree, inodes, chunk refs)"]
VaultFile --> DataRegion["Data region
fixed chunk slots"]
Header --> Keyslot0["Keyslot
AEAD-wrapped MVK"]
Keyslot0 --> AAD["Authenticated AAD
kind, salts, nonce, cred_id, header_salt"]
Header --> HMAC["HMAC-SHA256
over header bytes"]
MetadataRegion --> TreeValidation["Post-auth structural validation
root, parents, children,
chunk ids, generations, offset bounds"]
DataRegion --> Chunk["Chunk
nonce + AEAD(4096 bytes) + tag"]
Chunk --> ChunkAAD["Chunk AAD
file_id, chunk_index, generation"]
Detached["Optional detached .hdr"] --> Header
Hybrid["Optional .hybrid sidecar"] --> Keyslot0
Kyber["Optional .kyber seed"] --> Keyslot0
Anchor["Optional .anchor"] --> Rollback["Rollback comparison"]
Important properties:
- Header tampering is detected by HMAC after a keyslot unwraps the MVK.
- Keyslot tampering is detected by AEAD. The AAD covers slot type and all security-sensitive slot fields.
- Metadata is encrypted and structurally validated before use. Malicious authenticated metadata cannot wrap chunk offsets, create broken inode graphs, or smuggle in symlinks.
- Chunks are encrypted independently. Chunk AAD binds each chunk to its file, position, and generation counter.
- Detached headers remove the visible vault header from the
.lbx; without the sidecar, the vault file is random-looking data. - Header format v3 (
LUKSBOX3magic, the default for new vaults since 0.6.0) adds a header generation inside the HMAC-covered region, bumped by every keyslot change. See rollback detection below.
Rollback detection
An older copy of a vault still authenticates: it was written under
the same MVK. The optional .anchor sidecar is what detects it: it
records the vault's generation under an MVK-derived HMAC key, and an
open with --anchor refuses a vault older than the anchor. It only
protects if it lives on storage an attacker cannot roll back together
with the vault (a USB stick you carry, a second disk). Since 0.6.0:
- Every save of the file tree persists a strictly larger generation, so the anchor also catches a rollback across a metadata-only change (a deleted or renamed file), not only one that crossed a chunk write.
- On header format v3, every keyslot change (enroll, revoke, update,
rotate-mvk) bumps the header generation and the anchor records it,
in a 56-byte
LBXANCH2anchor (header v2 vaults keep the 48-byteLBXANCH1one). A restored older header, for example a backup taken before arevoke, still has a valid HMAC but is refused at an anchored open (header rollback detected). - Deniable v3 headers carry the generation inside the encrypted inner header and also commit to their slot table, so an old slot envelope spliced back in is refused even without an anchor.
--anchoris honoured by every command that unlocks a vault, not only the file-access ones: onenroll,revoke,update,header-restoreand the like, an older header is refused before the command runs, and the header change advances the anchor. A change made without the anchor only leaves it behind, which is accepted: only going below it is a rollback.header-restoreof a verified backup on a header-v3 vault re-seals it as a new header generation: a deliberate restore is not a rollback.
Header v3 vaults need LUKSbox 0.6.0+ to open. create --header-v2
(and deniable-init --header-v2) keeps the v2 header for vaults that
older versions must still open. On v2 the keyslot table carries no
generation, so a restored header goes unnoticed and rotate-mvk is
the only revocation that survives it. Existing vaults move to v3 with
upgrade-header or
deniable-upgrade-header (one-way), the wizard or the GUI. Design
notes:
docs/ROLLBACK_PROTECTION.md.
3. Unlock flow
sequenceDiagram
participant CLI as CLI / TUI / GUI
participant C as Container
participant L as File locks
participant H as Header
participant D as Device / PQ
participant V as VFS
CLI->>C: open(vault, optional header)
C->>L: open handles and take exclusive locks
L-->>C: locked or VaultLocked
C->>H: read header after lock
Note over C,H: lock-before-read serialises
concurrent enroll / revoke
CLI->>D: get passphrase / FIDO2 / TPM / PQ material
D-->>CLI: unlock material
CLI->>C: try matching keyslots
C->>H: verify header HMAC with MVK
C->>C: verify_path_inode (post-lock TOCTOU)
C-->>CLI: unlocked Container
CLI->>V: open VFS
V->>V: decrypt and validate metadata tree
V-->>CLI: usable vault
The lock-before-read rule is security-relevant: a second process can
no longer read an old header before the first process enrolls or
revokes a keyslot, then later persist stale keyslot state. The
post-lock path-inode re-stat catches narrow open-then-rename swaps.
With --anchor, the header generation is checked against the anchor
right after the header HMAC, and the metadata generation once the
metadata tree is decrypted.
4. Concurrency and crash safety
flowchart LR
Open["Container::open"] --> Lock["Exclusive lock on vault handle"]
Lock --> ReadHeader["Read header only after lock"]
ReadHeader --> Mutate["Enroll / revoke / metadata write / rotation"]
Mutate --> Flush["flush file data"]
Flush --> Fsync["fsync file"]
Fsync --> Rename["atomic rename when replacing files"]
Rename --> DirSync["fsync parent directory
(Unix + Windows)"]
DirSync --> Durable["directory entry durable"]
Current guarantees:
- One LUKSbox writer per vault path is allowed at a time.
- Detached-header opens lock both the vault file and the header sidecar.
- Atomic sidecar writes use owner-only temp files, fsync the temp
file, rename into place, then fsync the parent directory on Unix
(
fsyncon a dir handle) and Windows (FILE_FLAG_BACKUP_SEMANTICSFlushFileBuffers).
- Inline MVK rotation writes to a rotating temp file, fsyncs it, renames it over the original vault, then fsyncs the parent directory. The vault stays locked for the whole rotation, and an aborted rotation leaves the original header and MVK in place (Round 15).
- Header and metadata saves write a crash-recovery copy first
(
<vault>.lbx.header-bak,<vault>.lbx.meta-bak), then the live copy. An open that finds the live metadata failing AEAD, or the live header too damaged to parse, uses the copy and rewrites the live one. A header that parses but fails its HMAC is never replaced automatically, so the copy cannot silently undo arevoke. Deniable vaults keep no copies. - File data is copy-on-write (0.6.1+): overwriting a chunk that the saved metadata references writes a fresh slot, and the old slot is reused only after the next metadata save. A crash before that save loses the unsaved change but keeps the last saved contents readable. Chunks written since the last save are rewritten in place, so appends and new files cost nothing extra, and a write saves first once 256 MiB of old slots are waiting, which bounds the extra space. A vault at its maximum size with no free slot rewrites in place, without that protection for the chunk. Before 0.6.1, a power loss between an overwrite and the next save could leave the overwritten chunks failing AEAD, with neither old nor new contents readable.
Known limitation: detached-header MVK rotation is not yet a two-file atomic commit. A crash mid-rotation can leave the vault/header pair inconsistent. Back up the vault and its detached header before rotating in that mode: a header backup alone cannot decrypt chunks that were already re-keyed.
Vault size
A vault file grows as data is added; a deleted file's slots go to a
free list and are reused, and nothing moves data to close gaps. Since
0.6.1 a vault can have a maximum size: writes that would grow the file
past it fail with "no space left", as on a full disk. The limit is
stored in the encrypted metadata (LBM\x06, written only while a
limit is set); LUKSbox before 0.6.1 cannot open a vault that has one,
and a vault without a limit stays readable by older versions. The free
space at the end of the file can be given back to the disk
(luksbox size --shrink, mount --shrink-on-close, the GUI, the wizard). Deniable vaults are never
shrunk on their own, since a smaller file reveals deletions: the CLI
never shrinks them, and only the GUI's Vault size dialog can, after
the user ticks "I understand that a smaller file reveals deletions".
Design notes:
docs/VAULT_SIZE.md.
5. On-disk footprint
A LUKSbox vault touches up to seven durable files (created and
kept across operations), transient files that exist only while a
write is in progress (renamed away on commit), and a small number of
GUI-only state files. Knowing what's on disk, including the
stuff that's only there during a rename window, is part of the
threat model: it answers questions like "what does the cloud
provider see?", "what's in ~/.local/share/luksbox?", and "what
do these .tmp.<16hex> files mean if I find one?".
File family
flowchart TD
classDef durable fill:#1f5d3a,stroke:#34d399,color:#e5fff0,stroke-width:1.5px
classDef transient fill:#4a3211,stroke:#f59e0b,color:#fff3d6,stroke-width:1.5px,stroke-dasharray:4 3
classDef state fill:#1e2a4a,stroke:#60a5fa,color:#dbeafe,stroke-width:1.5px
Vault["my-vault.lbx
vault file
header + metadata + chunks
(or metadata + chunks only,
if --header is set)"]:::durable
Hdr["my-vault.lbx.hdr
OPTIONAL detached header"]:::durable
Anchor["my-vault.lbx.anchor
OPTIONAL rollback detector
separate storage"]:::durable
Hybrid["my-vault.lbx.hybrid
OPTIONAL PQ sidecar"]:::durable
Kyber["my-vault.kyber
OPTIONAL PQ decap seed
separate storage"]:::durable
Bak["my-vault.lbx.header-bak
my-vault.lbx.meta-bak
crash-recovery copies
(not for deniable vaults)"]:::durable
Tmp["file.tmp.16hex
TRANSIENT atomic_secure_write
0600, renamed on commit"]:::transient
Rotating["my-vault.lbx.rotating
TRANSIENT MVK rotation
full vault copy, 0600"]:::transient
Recent["$XDG_DATA_HOME/luksbox/recent.json
GUI ONLY recent vault list
0700 dir + 0600 file"]:::state
Prefs["$XDG_DATA_HOME/luksbox/preferences.json
GUI ONLY preferences
0700 dir + 0600 file"]:::state
Vault -.- Hdr
Vault -.- Anchor
Vault -.- Hybrid
Hybrid -.- Kyber
Vault -.- Bak
Bak -.->|atomic write| Tmp
Vault -.->|atomic header / metadata write| Tmp
Anchor -.->|atomic write| Tmp
Hybrid -.->|atomic write| Tmp
Kyber -.->|atomic write| Tmp
Vault -.->|begin_atomic_rotation| Rotating
Recent -.->|atomic write| Tmp
Prefs -.->|atomic write| Tmp
Atomic-write lifecycle
Every durable update goes through one helper,
atomic_secure_write:
sequenceDiagram
participant App as caller
participant Tmp as file.tmp.16hex
participant Final as file
participant Dir as parent dir
App->>Tmp: secure_create_new (O_RDWR, O_CREAT, O_EXCL, mode 0600)
Note over Tmp: 16-hex random suffix prevents temp races. O_EXCL refuses to clobber a stale orphan.
App->>Tmp: write payload
App->>Tmp: fsync
App->>Final: rename file.tmp.16hex to file
Note over Final: rename(2) is atomic on POSIX. On Windows we use MoveFileExW with REPLACE_EXISTING.
App->>Dir: fsync (POSIX dir handle. Windows uses FILE_FLAG_BACKUP_SEMANTICS plus FlushFileBuffers.)
Per-artifact summary
| Artifact | Mode | Atomic? | Used by | What's in it |
|---|---|---|---|---|
<vault>.lbx (inline) |
0600 | Yes, temp + rename for header rewrite, .rotating for MVK rotation | Always | Header (8 KiB) + encrypted metadata + chunk array |
<vault>.lbx (detached mode) |
0600 | Yes | When --header is used |
Encrypted metadata + chunk array only, no magic, no version, no keyslots |
<vault>.lbx.hdr |
0600 | Yes | When --header is used |
The 8 KiB header (magic, params, keyslots, HMAC) |
<vault>.lbx.header-bak |
0600 | Yes | Standard vaults (v0.2.1+ format), not deniable ones | Crash-recovery copy of the 8 KiB header, written before the live header. Next to the detached header when --header is used |
<vault>.lbx.meta-bak |
0600 | Yes | Standard vaults (v0.2.1+ format), not deniable ones | Crash-recovery copy of the encrypted metadata region (64 MiB by default) |
<vault>.lbx.anchor |
0600 | Yes | When the user opts into rollback protection | 48 bytes (LBXANCH1): vault generation counter + HMAC under an MVK-derived anchor key. 56 bytes (LBXANCH2) on header-v3 vaults: also the header generation. Deniable anchors: 256 random-looking bytes |
<vault>.lbx.hybrid |
0600 | Yes (v3 with header binding) | Hybrid PQ vaults | Per-slot PQ encap key + ciphertext, plus a 32-byte vault binding |
<vault>.kyber |
0600 | Yes | Hybrid PQ vaults | 64-byte ML-KEM seed; intended to live on separate storage |
<file>.tmp.<16hex> |
0600 | Transient by design | Every atomic write site | Partial / fully-written but un-renamed payload of one of the artifacts above |
<vault>.lbx.rotating |
0600 | Two-file commit | luksbox rotate-mvk (inline mode) |
Full byte-for-byte copy of the vault, then rewritten under the new MVK |
$XDG_DATA_HOME/luksbox/recent.json |
0600 file in 0700 dir | Yes | GUI only | Recent vault paths + sidecar locations + capability flags (FIDO2 / TPM / hybrid PQ / cipher). Never deniable vaults (0.6.0+) |
$XDG_DATA_HOME/luksbox/preferences.json |
0600 file in 0700 dir | Yes | GUI only | Preference flags (panic-warning ack, last cipher choice) |
The recent.json file is the only artifact in this list that
contains structural intelligence about a vault from outside the
vault directory. On a multi-user host, an attacker with access
to the user's home dir can enumerate which vaults exist, where
their sidecars live, and which authenticators are enrolled,
without touching the vaults themselves. The 0700 dir + 0600 file
contract is therefore enforced regardless of umask. Deniable vaults
are never written to it (0.6.0+), since a recorded path would give
them away. The CLI does not write either file.
Crash-orphan classification
classify_tempfile_suffix in
crates/luksbox-core/src/file_util.rs
is the source of truth for what each leftover file means:
| Filename pattern | Origin | Recovery |
|---|---|---|
<base>.tmp.<exactly-16-lowercase-hex> |
atomic_secure_write orphan from a crashed sidecar / header rewrite |
Safe to delete after confirming the original target file opens cleanly. The original is unchanged because the rename never happened. |
<vault>.lbx.rotating |
begin_atomic_rotation orphan from a crashed MVK rotation |
If the rotation hadn't reached commit_atomic_rotation, the original vault is intact, delete the .rotating file and the rotation is undone. Never delete a .rotating file without first confirming the original vault opens. |
<vault>.lbx.header-bak, <vault>.lbx.meta-bak |
Not orphans: the crash-recovery copies above | Keep them with the vault (see Moving or copying a vault). |
Any other .tmp / .bak / ~ etc. |
Not produced by LUKSbox; from the user's editor or a third-party sync tool | Out of scope; treat per the producing tool's recovery contract. |
luksbox info warns when it detects an orphan in the vault's
directory at open time. The CLI / GUI / wizard never silently
delete an orphan, operator action is always required.
For the spec-level treatment see
docs/CRYPTO_SPEC.md sec.3.4-3.8.
6. Secret-memory hygiene
The MVK lives in memfd_secret(2) pages on Linux 5.14+ where the
kernel excludes them from coredumps and hibernate images. On older
kernels and on macOS / Windows, the MVK falls back to mlock-pinned
pages with Zeroize on drop.
Since the Round 15 audit (0.6.0), the memfd_secret descriptor is
close-on-exec: before, a child process LUKSbox started (xdg-open,
fusermount, the FUSE-T helper) inherited it and could map the MVK
pages. In the fallback, each secret gets its own private, locked page
(on Linux also excluded from core dumps and wiped in forked children),
so dropping one key no longer unlocks the page under another.
A workspace-wide audit landed in 2026-05 covering the full MVK
lifecycle. Every intermediate buffer along the derivation chain (AEAD
plaintext from keyslot unwrap, HKDF input/output buffers in all KEK
derivations, ML-KEM shared-secret destination, CLI / GUI PIN copies)
is now wrapped in Zeroizing so its bytes are scrubbed on drop,
including panic and early-return paths. The TPM PIN avoids an
intermediate unzeroized Vec<u8> by passing the slice straight into
tss-esapi::Auth::try_from.
Round 15 wiped the copies that audit had missed: the KEK round keys inside the AEAD temporaries, the FIDO2 PIN copy handed to libfido2, the ML-KEM seed and shared-secret copies, the Secure Enclave shim's copy of the shared secret, the deniable unlock's MVK copies, and the stdin buffer used for piped passphrases and the FUSE-T MVK handoff. The full report, with each finding and its regression test, is docs/SECURITY_AUDIT_ROUND_15.md; see also the audit history.
7. Symlink handling
| Surface | Behaviour |
|---|---|
| Vault-internal symlinks | Impossible by design
, InodeKind enum has only File / Directory; validate_metadata_tree enforces it; FUSE rejects symlink()/link(); WinFsp ignores reparse points. |
.lbx / .hdr path is a symlink |
Defended
, open_rw_checked + verify_path_inode re-stats after lock; LUKSBOX_NO_FOLLOW_SYMLINKS=1 paranoid mode refuses any symlinked vault path. |
luksbox put source path is a symlink |
By design, follows symlink
, cat-like semantics; user picked the source. |
luksbox get destination is a pre-existing symlink |
Defended
, secure_create_or_truncate opens via openat(parent_dir_fd, basename, O_NOFOLLOW) against a canonical parent fd (R13-01); intermediate-directory symlinks are followed once at canonicalize time, then bound; the final basename open refuses any symlink. Windows mirrors with FILE_FLAG_OPEN_REPARSE_POINT + attribute rejection. |
.kyber seed-file path is a symlink |
Defended
, O_NOFOLLOW + regular-file + fixed-length preflight (R13-05); refuses FIFOs, devices, and oversize swaps before any allocation. |
.hybrid sidecar path is a symlink |
Defended
, O_NOFOLLOW + 32 KiB size cap + Windows reparse-point rejection (R12-06 + R13-06). |
luksbox header restore path race |
Defended
, Container::restore_header_bytes reuses the verified self.file handle (inline) or routes through atomic_secure_write (detached) -- no post-verify reopen (R13-02). |