Files
vault/docs/COMPATIBILITY.md
Andy DixonandClaude cddde5ca52 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>
2026-10-08 11:44:23 +01:00

10 KiB
Raw Permalink Blame History

Compatibility specification

This is the on-disk and on-wire contract between the Go implementation (frozen in oracle/go, commit 4c1507c) and the Rust implementation. It is derived from the Go sources and pinned by vectors the Go oracle generated (tests/vectors, regenerated with oracle/gen-vectors.sh). The Rust port is an implementation migration, not a format migration: nothing here changes, no data is rekeyed or converted, and no new suite becomes the default.

Each section names the tests that hold the Rust code to it. "Go" below always means the oracle.

1. Key file

Item Value
Encoding one PEM block, type VAULT KEY, standard base64 with padding, 64 columns, as Go's pem.Encode
Body exactly 198 bytes: an 18-byte header and two 90-byte slots
Header VAULTKEY1, Argon2id time 3 (u32 BE), memory 65536 KiB (u32 BE), lanes 4 (u8)
Slot 16-byte salt, 24-byte XChaCha20-Poly1305 nonce, 34 bytes sealed (u16 BE suite ID, 32-byte master seed), 16-byte tag
Slot key Argon2id v1.3 (passphrase bytes as given, no normalisation, salt, t=3, m=64 MiB, p=4, 32 bytes)
Associated data the 18-byte header
Second slot a decoy identity's slot, or 90 random bytes; which slot is real is random
Unlock Argon2id runs for both slots every time (Rust runs them concurrently); the first slot that opens wins; "incorrect passphrase" if neither

Accepted framing: optional text before the block (Go's pem.Decode), only whitespace after it, header lines inside the block ignored. Files over 64 KiB are refused outright (Go would read them, then reject them).

Tests: keyfile::tests::* (Argon2id vectors, Go-written key files with and without decoy open to the Go-recorded seeds, byte-identical PEM re-encoding, malformed framing, NFC/NFD passphrases differ).

2. Hybrid suite 0x0001, tag frodo640shake+mlkem768

  • Master seed: 32 bytes. Expansion: HKDF-SHA-256(ikm = seed, salt = none, info = "fury/v1 seed-expand frodo640shake+mlkem768"), read as 48 bytes of FrodoKEM seed then 64 bytes of ML-KEM seed.
  • FrodoKEM-640-SHAKE exactly as CIRCL v1.6.4 kem/frodo/frodo640shake (the round-3 FrodoKEM, not the ISO/eFrodoKEM variants): key seed s(16) || seedSE(16) || z(16); public key 9616 bytes (seedA || pack(B)); private key 19888 bytes (s || pk || S as u16 LE || H(pk)); ciphertext 9720 bytes; 16-byte shared secret; 16-byte encapsulation seed mu; implicit rejection returns SHAKE128(ct || s).
  • ML-KEM-768 (FIPS 203) with seed d || z; encapsulation key 1184 bytes, expanded decapsulation key 2400 bytes, ciphertext 1088 bytes, 32-byte shared secret, implicit rejection.
  • Public key: FrodoKEM public key || ML-KEM encapsulation key (10800 bytes). Private key (for comparison only): FrodoKEM private || ML-KEM expanded.
  • Wrap: fresh, independent randomness for both encapsulations every time. KEK = HKDF-SHA-256(ikm = ss1 || ss2, salt = none, info = "fury/v1 kek frodo640shake+mlkem768" || ct1 || ct2); the 32-byte file key is sealed with ChaCha20-Poly1305 under the KEK and an all-zero 12-byte nonce. The zero nonce is safe only because each KEK is new and used once; the Rust code keeps that sealing function private to the suite.
  • Stanza: type frodo640shake+mlkem768, two arguments base64(ct1), base64(ct2), body = 48-byte sealed file key.
  • Unwrap: a stanza of another type is "not mine"; wrong argument count, bad base64 or wrong ciphertext lengths are fatal for the file; an AEAD failure (wrong key) is "not mine". The first stanza that opens wins.

Rust implementation: FrodoKEM is a line-by-line port of CIRCL (src/crypto/frodo.rs) because no maintained Rust crate implements CIRCL's variant (RustCrypto frodo-kem 0.1 implements the salted ISO version with 9752-byte ciphertexts). ML-KEM is RustCrypto ml-kem 0.3.2.

Tests: crypto::frodo::tests::nist_kat_digest (reproduces the SHA-256 CIRCL checks against the FrodoKEM reference KAT file), crypto::kem::tests::{frodo_matches_circl, mlkem_matches_circl, hybrid_derivation_matches_go, wrap_matches_go_and_unwraps, fresh_wraps_differ_and_foreign_keys_do_not_match}.

3. Fury header

fury-encryption.org/v1\n
-> TYPE ARG ARG\n                 per stanza; TYPE and ARGs are 1+ bytes of 0x21-0x7e
BODY, 64 base64 columns per line, ending with a line shorter than 64
--- MAC\n                         MAC = base64 of 32 bytes
  • Base64: standard alphabet, no padding, non-zero trailing bits rejected, CR or LF anywhere in an encoded value rejected.
  • A body whose encoding fills its last line (the 48-byte wrapped key encodes to exactly 64 characters) is followed by an empty line; serialisation is canonical and Rust reproduces Go's bytes exactly.
  • Limits: 1 MiB per stanza (all its lines), 16 MiB per header; a line must end in LF; three bytes of look-ahead decide between a stanza and the footer; the payload starts immediately after the footer's LF, with any read-ahead returned first.
  • MAC: HMAC-SHA-256(key = HKDF-SHA-256(file key, salt none, info "header"), message = header up to and including "---"), compared in constant time.
  • Payload: 16-byte nonce, then STREAM keyed by HKDF-SHA-256(file key, salt = nonce, info "payload").

Tests: crypto::fury::tests::{header_vectors_match_go, header_parser_agrees_with_go, decrypts_go_files} (the parser's accept or reject verdict, and the payload bytes left after the header, match Go on every case in crypto.json), crypto::header::tests::*, tests/interop_crypto.rs (Go decrypts files Rust wrote, and rejects truncated, extended and corrupted ones).

4. STREAM

  • 64 KiB plaintext chunks; ChaCha20-Poly1305; 16-byte tag per chunk.
  • Nonce: 12 bytes, bytes 3..11 the big-endian chunk index, last byte 1 on the final chunk only, 0 otherwise.
  • Only the final chunk may be short; it may be empty only when it is the only chunk (empty plaintext). A plaintext that is an exact multiple of 64 KiB ends with a full-length final chunk.
  • Rejected: no final chunk (truncation), an empty final chunk after others, bytes after the final chunk, any chunk failing authentication, a payload length no STREAM encoding can have.
  • The index never wraps; Rust refuses to go past 2^64 − 1 chunks (Go's counter is 88 bits; neither limit is reachable in practice).
  • Random access (StreamReaderAt) authenticates the final chunk when constructed, then any chunk on demand.

Tests: crypto::stream::tests::* (Go vectors for sizes around every boundary, any write/read step sizes, malformations rejected identically by the streaming, in-memory and random-access readers, concurrency of the random-access reader, counter overflow).

5. Store

  • DIR/.vault contains exactly VAULT 2\n.
  • DIR/records/xx/ID and DIR/objects/xx/ID: ID is 16 bytes as lowercase hex, xx its first byte; all 256 shards of both exist after init. Entries whose names are not exactly that form, or are in the wrong shard, are ignored.
  • Every ciphertext, every shard and both top directories have access and modification times pinned to the Unix epoch; removals re-pin the shard.
  • Record: a Fury file whose plaintext is exactly 4096 bytes: a u32 BE JSON length (≤ 4092), the JSON, random padding. JSON is Go's encoding/json output for {"name","seed","size","mode","modified","added"}: HTML characters and U+2028/2029 escaped, times RFC 3339 with up to nine fractional digits, trailing zeros removed, Z. Rust writes it byte-identically.
  • Validation on read: name valid (below), seed 64 lowercase hex characters, size 0..=1 TiB, both times valid RFC 3339; unknown fields refused.
  • Names: non-empty UTF-8, ≤ 1024 bytes, already clean (Go path.Clean), relative, not ./../../…, no backslash, no control characters.
  • Object IDs: HMAC-SHA-256(key = seed, msg = index as big-endian i64)[:16].
  • Objects: plaintext = own ID (16 bytes), then up to 4 MiB − 16 file bytes, then random padding to the next power of two from 64 KiB to 4 MiB. An empty file still has one object. These sizes are plaintext; the ciphertext adds the header (~14.6 KB) and 16 bytes per 64 KiB.
  • Reading an object checks: it opens, its first 16 bytes are its ID, its length is exactly the padded size for its part, and the whole STREAM (final chunk, end of file) authenticates.
  • Records the key cannot open (no stanza matches) are skipped silently; ones that open but are malformed are reported and skipped.
  • Duplicates (an interrupted replace) are ordered newest added first; the next write of that name removes them all.
  • Filler: init writes 8–32 entries (or more, to reach --filler) of log-uniform sizes from 1 KiB to clamp(target/8, 4 MiB, 1 GiB), sealed to a throwaway key, through the same code as add.

Tests: store::types::tests::* (names, object IDs, padded sizes, object counts, record JSON byte-identical to Go), store::tests::reads_go_vault (the oracle's fixture vault: real files, decoy file, foreign record), store::tests::*, tests/cli.rs lifecycle tests in both directions.

6. Locks

DIR/.lock, created mode 0600. Unix: POSIX fcntl record locks over the whole file (F_SETLK then F_SETLKW), shared for readers, exclusive for writers, released by the kernel when the process dies. Windows: LockFileEx on byte 0, length 1. Other systems: an O_EXCL lock file. Tests: tests/cli.rs::lock_* (Rust waits for Go, Go waits for Rust, both proceed after the holder is killed).

7. Agent

Socket $XDG_RUNTIME_DIR (else $TMPDIR, else /tmp) /vault-UID/agent- followed by the first 8 bytes of SHA-256 of the key file's absolute path (Go filepath.Abs, including its use of $PWD) in hex. The directory must be the user's own with mode exactly 0700. One request per connection: g → 34 bytes (u16 BE suite ID, seed); q → the agent wipes the key and closes. Two-second deadline per request. Tests: tests/cli.rs::agent_* (Rust client with Go agent and the reverse).

8. What is deliberately not identical

See docs/MIGRATION.md for the full list of intentional behaviour changes. None of them alters a byte that is written to the vault or the key file beyond what the Go format already allows (fresh randomness, timestamps).