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
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.
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.
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.
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.
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 detached header before rotating in that mode.
5. On-disk footprint
A LUKSbox vault touches up to eight distinct files plus a small
number of GUI-only state files. Five are durable (created and kept
across operations); three are transient (created during a write,
renamed away on commit). 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
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 -.->|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.anchor |
0600 | Yes | When the user opts into rollback protection | 48 bytes: vault generation counter + HMAC under an MVK-derived anchor key |
<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) |
$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. 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. |
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.
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.
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). |