Files
vault/SECURITY.md
T
Andy DixonandClaude 91d49aeecf Fix review findings: PAX sizes, agent stdin, PEM parity, sticky writer
- absorb uses the PAX-corrected entry size (large Go/bsdtar archives were
  stored truncated) and refuses GNU sparse entries
- the agent reads the key from fd 0 straight into locked memory, and never
  unlinks a live agent's socket
- the key file PEM parser is a port of Go's pem.Decode
- the STREAM writer fails permanently after a write error; plaintext is
  cleared on reader error paths; AEAD, HMAC and hash states are zeroized
- add/migrate refuse paths inside the vault; publish reports filesystems
  without an atomic no-replace primitive clearly and treats a leftover
  temporary name as a warning; Windows rejects NTFS-special names
- the leak test runs in its own process

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

157 lines
7.9 KiB
Markdown

# 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, `mlock`s 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 platform verifier; it 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.