/ Documentation / Security / Security architecture

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:

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:

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.

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).