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>
10 KiB
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 seeds(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 seedmu; implicit rejection returnsSHAKE128(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 argumentsbase64(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
1on the final chunk only,0otherwise. - 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/.vaultcontains exactlyVAULT 2\n.DIR/records/xx/IDandDIR/objects/xx/ID:IDis 16 bytes as lowercase hex,xxits first byte; all 256 shards of both exist afterinit. 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/jsonoutput 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 − 16file 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
addedfirst; the next write of that name removes them all. - Filler:
initwrites 8–32 entries (or more, to reach--filler) of log-uniform sizes from 1 KiB toclamp(target/8, 4 MiB, 1 GiB), sealed to a throwaway key, through the same code asadd.
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).