Cross builds, packaging split, CI, fuzzing, supply-chain policy
build.sh builds the Rust targets with the Go-era names, refuses an OUT that is /, $HOME or the tree, checks static linking and names the Go targets Rust cannot build. packaging/package.sh builds and inspects packages without signing or publishing; repo.sh reuses it. Seven fuzz targets (one found a URL-parser panic, fixed here with a regression test), cargo-deny policy, CI for lint, tests on Linux/macOS/Windows, cross builds with qemu runs, supply chain and fuzzing. Compatibility specification and security notes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
# 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, 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.
|
||||
- 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 private
|
||||
(0600) temporary file in `$TMPDIR` before storing them, because the size
|
||||
must be known first; it is 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.
|
||||
Reference in New Issue
Block a user