Files
vault/SECURITY.md
Andy DixonandClaude 8aee1f0f04 Screenshots from the Rust build; system-roots feature for macOS cross-builds
docs/guide/img is re-shot from the 0.6 binary. TLS roots come from the
operating system by default; build.sh cross-builds macOS on Linux without
that feature (no Apple SDK), trusting the bundled Mozilla roots. Lock
tests wait for the reader to report it is blocked rather than assuming
how long unlocking takes on a busy machine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-08 18:27:32 +01:00

8.0 KiB

Security notes

What vault protects, how, and where the protection stops. See docs/COMPATIBILITY.md for the exact formats.

What is protected

  • Contents, names, sizes, modes and times of stored files, against anyone who reads the vault directory without the passphrase: everything is inside Fury files sealed to a hybrid FrodoKEM-640 + ML-KEM-768 key, so the file key stays secret unless both KEMs are broken.
  • Structure: records are one fixed size; objects are at most 4 MiB and padded to power-of-two buckets; object names derive from a secret per-file seed, so nothing on disk links a file's objects; every ciphertext and shard directory has its timestamps pinned to the epoch; all 256 shards exist from the start; init adds filler sealed to a discarded key.
  • Integrity: every byte that is acted on is authenticated (header MAC, STREAM tags with a final-chunk flag, no trailing data); an object starts with its own ID, so objects cannot be swapped, reordered or replayed under another record; get publishes output only after the whole file, padding and end of file included, has authenticated.
  • Deniability: the key file always has two equal slots, one possibly random; both are always tried; decoy and real files are indistinguishable from filler to the other passphrase.

What is not protected

  • The number and total size of ciphertexts, and when the directory changes, are visible to whoever stores it.
  • A passphrase is only as strong as it is: Argon2id (3 passes, 64 MiB, 4 lanes) slows guessing, it does not prevent it.
  • Anyone who can run code as you while the vault is unlocked (or watch you type the passphrase) can read everything.
  • Nothing here hides that you use vault.

Secrets in memory

Passphrases, seeds, private keys, key-encryption keys and plaintext buffers are held in owned types (SecretArray, SecretVec, LockedBuf) that are not Clone, Copy, Debug or serialisable, have fixed allocations that never reallocate, and are cleared with zeroize when dropped — on success, on error and while unwinding from a panic (release builds unwind).

What cannot be cleared, so is not claimed:

  • copies the compiler leaves in registers or on the stack;
  • copies inside third-party key objects: chacha20poly1305 cipher states (the key schedule), ml-kem's Seed passed by value (the decapsulation key itself is zeroized on drop with the crate's zeroize feature), hmac/hkdf internal states, argon2 internals other than its working memory (which vault allocates and clears itself);
  • the passphrase in $VAULT_PASSPHRASE (it is in the process environment for the process lifetime) and in the terminal driver's buffers;
  • anything after abort, kill -9, a power cut, or once swapped out;
  • file data in the OS page cache, and plaintext temporary files on disk (below).

None of this is erasure from physical memory.

The agent

vault unlock re-executes the binary as vault __agent with VAULT_PASSPHRASE and VAULT_DECOY_PASSPHRASE removed from its environment, no secret in its arguments, its working directory / and standard error closed. It hands over the 34-byte suite ID and seed through a pipe. Before reading it (straight from the pipe into the locked page, with no intermediate buffer), the agent:

  1. sets the core-dump size limit to zero;
  2. on Linux, sets PR_SET_DUMPABLE to 0 (no core dumps, no same-user ptrace, /proc/PID/mem or /proc/PID/environ) and checks it took;
  3. maps a private page, mlocks it and, on Linux, marks it MADV_DONTDUMP, all checked.

Any failure is reported to unlock and the agent exits without the key; there is no fallback to unprotected memory. On other Unix systems (macOS, the BSDs, illumos) steps 1 and 3 apply; there is no portable equivalent of PR_SET_DUMPABLE, so another process of the same user may be able to read the agent's memory with a debugger.

The socket lives in a directory that must be owned by you with mode exactly 0700 (refused otherwise); the socket is 0600, checked before readiness. Each request has a two-second deadline; the expiry deadline is enforced with a timed poll, so it does not depend on another client arriving, and a key is never handed out after expiry. On exit the agent clears the page and removes the socket only if it is still its own. unlock waits at most ten seconds for the agent to report and kills it otherwise.

Windows: the agent is not supported (unlock says so); every command asks for the passphrase.

Filesystem safety

  • Output is published with an atomic no-replace primitive (renameat2(RENAME_NOREPLACE), renameatx_np(RENAME_EXCL), link+unlink, or MoveFileExW without replace), so an existing file, symlink (dangling or not) or one created concurrently is never replaced; only vault's own temporary file is removed. There is no check-then-rename fallback.
  • The temporary output file is mode 0600 until the content has fully authenticated; only then are the recorded mode and times applied.
  • get -r opens each directory below the chosen destination with O_NOFOLLOW|O_DIRECTORY relative to its parent, so a symlink planted inside the destination cannot redirect files elsewhere. On Windows, which has no openat, each component is checked not to be a link or junction just before use; a concurrent swap between check and use is not excluded.
  • Writes follow crash-safe order with explicit fsync of files and directories (Unix): objects before their record; a new record before the removal of the one it replaces; a record's removal before its objects'. A failure after a record is already in place never deletes its objects. Windows has no directory fsync through the APIs used; NTFS journals metadata.
  • migrate checks an original is still the file it stored (device, inode, size, time; on Windows only size and time) and then deletes it by name; a replacement in the instant between the check and the deletion is not excluded. Paths inside the vault directory are refused outright.
  • A filesystem with neither RENAME_NOREPLACE nor hard links (some FUSE mounts, FAT) cannot publish without risking a replace, so get fails there with a message saying so rather than fall back.
  • The vault lock uses the same POSIX fcntl locks as the Go version, so mixed Go and Rust processes exclude each other. Only a read-only filesystem lets a reader proceed without the lock.

Plaintext on disk

  • vault add URL and vault mirror stream non-HTML downloads to a 0600 temporary file in $TMPDIR before storing them, because the size must be known first. On Unix the file is unlinked as soon as it is created, so it has no name and disappears even if vault is killed; on other systems it is a named file removed on every normal path, including errors. HTML and CSS are rewritten in memory. vault absorb stores tar entries straight from the stream with no temporary file.
  • vault get writes plaintext where you ask; vault migrate deletes the originals with unlink, which is not secure deletion.

Network

Downloads verify TLS certificates (rustls with the operating system's trust store; macOS binaries cross-built on Linux use the bundled Mozilla roots instead, see docs/PLATFORMS.md; verification is never disabled), follow at most 10 redirects (http and https only; for mirror, only to the same host), and are bounded by connect, response and whole-request deadlines and a byte budget counted after decompression. See vault(1) for the defaults and flags.

Cryptographic implementations

FrodoKEM-640-SHAKE is a port of CIRCL v1.6.4's implementation (constant-time in the same places: no secret-dependent branches or indices; the re-encryption check selects the rejection key with a mask). It reproduces CIRCL's NIST KAT digest. ML-KEM-768 (ml-kem), ChaCha20-Poly1305 and XChaCha20-Poly1305 (chacha20poly1305), Argon2id (argon2), HKDF, HMAC, SHA-2 and SHA-3 are RustCrypto crates; TLS is rustls with ring. A Rust rewrite is not stronger cryptography: the algorithms and parameters are unchanged.

Reporting

Please report vulnerabilities privately to andy@dixon.cx.