Introduce a central policy module for format and resource validation, then
route header parsing, KDF acceptance, range arithmetic, and pipeline sizing
through that policy. New encryptions now write v3 headers that include an
authenticated key commitment, which lets decrypt reject wrong keys or
passphrases before chunk processing while preserving valid v1/v2 decrypt
compatibility inside the configured caps.
Replace process-list-visible raw key input with --key-file, add passphrase NFC
normalization, enforce stronger new-encryption passphrase/KDF floors unless
--allow-weak-kdf is supplied, and add a configurable decrypt Argon2 memory
ceiling. Chunk buffers in the serial, parallel, and lookahead paths now use
zeroizing storage.
Rework output handling around randomized create-new temporary files with Unix
0600 mode, file fsync before persist, best-effort parent directory fsync,
default no-overwrite behavior, safe in-place replacement, --force, --temp-dir,
and --buffer-verify for decrypt-to-stdout.
Known caveat: --key-file currently reads with a single read call. That is fine
for regular files but can reject short reads from pipes or process
substitution. A follow-up fix will make key-file reads loop before EOF.
Test Plan:
- cargo fmt --check
- cargo clippy --all-targets -- -D warnings
- cargo test
- git diff --check
- cargo run -- --help
Refs: fcry security hardening plan
Completes the two follow-ups deferred from the v0.10 format/secrets
work: multi-threaded AEAD encrypt/decrypt and a length-committed file
format that enables random-access decryption.
# Format change (file format v2)
Bumps the on-disk header version to 2 and introduces a flag bit
(`FLAG_LENGTH_COMMITTED`, bit 0). When set, an authenticated `u64 LE`
plaintext length is appended to the header after the nonce prefix. v1
files still decrypt unchanged. v2 readers reject unknown flag bits.
The flag is set automatically when the input is a regular file (we
stat the open FD to avoid TOCTOU). Stdin/pipes/FIFOs encrypt as before
with the flag clear. Sequential decrypt cross-checks the produced byte
count against the committed length as defense in depth (the AEAD
already authenticates the value via header AAD, but failing before we
rename the temp file into place is preferable to failing after).
# Random-access decrypt
`fcry -d -i FILE --offset N --length L` seeks directly to the chunk(s)
covering `[N, N+L)` and decrypts only those, without scanning the
predecessors. Requires a seekable file whose header has the
length-committed flag — stdin/pipe-encrypted files cannot use this
path and the CLI rejects it with a clear error.
The chunk layout is fully determined by `chunk_size` and the committed
total length (last chunk's plaintext is
`total - (n_chunks-1)*chunk_size`; its ciphertext length is
`last_pt + 16`). Each chunk's nonce is
`make_nonce(prefix, chunk_index, is_last_chunk)` which matches what
sequential encrypt produced, so plaintext slices come out
bit-identical to a full sequential decrypt.
# Multi-threaded pipeline
New `src/pipeline.rs` implements:
reader thread → bounded jobs channel → N AEAD workers
→ bounded results channel → writer thread
The reader stays serial (it owns the input handle and uses lookahead
to detect the last chunk). Workers parallelize the AEAD step (each
chunk is independent under STREAM). The writer holds a
`BTreeMap<u32, Vec<u8>>` reorder buffer and only flushes in counter
order. Commit is deferred to the main thread, so a failure anywhere —
reader I/O, AEAD auth, writer I/O — drops `OutSink` without renaming
the temp file into place. The
`atomic_output_no_stale_tmp_on_failure` integration test still
passes.
Channel and reorder capacities scale with worker count (`2*threads`);
peak memory is roughly `chunk_size * 4 * threads`. With 1 MiB chunks
and 8 cores that's ~32 MiB, which we accept.
Default thread count is `std::thread::available_parallelism()`;
override with `-j/--threads N`. `-j 1` keeps the original serial path.
Stdin/stdout streaming works under the parallel path because `Stdin`
(unlocked) is `Send` — only `StdinLock` isn't, so the boxed reader
wraps `Stdin` directly in a `BufReader`.
Adds `crossbeam-channel = "0.5"` for bounded MPMC. The cipher
(`XChaCha20Poly1305`) and the header AAD are shared across workers via
`Arc`; the AEAD's internal key copy is zeroized on drop as before.
# CLI surface
-j, --threads <N> worker thread count (default: cores)
--offset <BYTES> random-access decrypt: slice start
--length <BYTES> random-access decrypt: slice length
`--offset`/`--length` require `--decrypt` and `--input-file` (clap
enforces; we also surface a clean runtime error if only one is
supplied).
# Test plan
* `cargo test` — 5 unit + 27 integration, all green.
* New integration coverage:
- parallel roundtrip on multi-chunk inputs (`-j 4`)
- parallel-encrypted ciphertext decrypted serially, and vice-versa
(output bit-identical regardless of worker count)
- parallel pipe stdin↔stdout (asserts flag byte is 0 for stdin
inputs — no length committed without a known size)
- file inputs auto-commit length (asserts version=2 and flags bit 0
set in the raw header bytes)
- random-access slices spanning chunk-aligned, mid-chunk,
last-chunk, and full-file ranges
- random-access rejects out-of-range and stdin-encrypted inputs,
accepts zero-length
- tampering the committed length byte fails AEAD authentication
- hand-crafted v1 header still decodes (no flag bit set)
* `cargo clippy --all-targets -- -D warnings` clean.
* `cargo +nightly fmt` clean.
Removes `TODO.md` since both deferred items are now implemented.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce a self-describing on-disk format and use it to address several
shortcomings of the 0.9 file layout, where the file simply began with a
raw 19-byte STREAM nonce prefix and used a hardcoded 64 KiB chunk size.
What changed for users
----------------------
* fcry files now start with a 16-byte header: magic ("fcry"), version,
algorithm id, flags, reserved byte, plaintext chunk_size (u32 LE),
KDF id + params, then the 19-byte nonce prefix. The full encoded
header is bound as AAD to every chunk, so tampering with chunk_size,
algorithm id, nonce prefix, or any future KDF parameter causes
authentication failure on every chunk -- not just the first.
* New `--chunk-size` CLI flag (encryption only). The decryptor reads
the chunk size from the header, so files encrypted with a non-default
size decrypt without the user having to remember it.
* Default plaintext chunk size raised from 64 KiB to 1 MiB.
* Bad input is now reported as an error instead of panicking: empty
ciphertext, truncated final chunk, wrong magic, bad version, zero
chunk_size, unknown algorithm id, and short --raw-key all return a
non-zero exit status with a diagnostic on stderr.
* Empty plaintext now produces a valid (authenticated) empty
ciphertext instead of panicking; the decryptor verifies it.
* `main` exits with status 1 on error (previously it printed and
returned 0).
This is a breaking change to the file format: 0.9.x files have no magic
or header and cannot be read by 0.10.x. Version bumped to 0.10.0.
Why this approach
-----------------
The header-as-AAD pattern is the standard way to make file-format
metadata tamper-evident without a separate signature: any bit-flip in
the header propagates into every chunk's authentication tag check, so
an attacker cannot, for example, change chunk_size to mis-frame the
stream or downgrade the algorithm id.
Storing chunk_size in the header (rather than fixing it at compile
time) lets us experiment with chunk sizes without breaking decrypt
compatibility, and is preparation for the parallel-pipeline work in
Roadmap 1.0 where worker count and chunk size interact.
The KDF section is a tagged variant (currently only `Raw`) so that
adding Argon2id later only adds a new variant + its salt/cost fields;
existing files keep decrypting because they carry `kdf_id = 0`.
Other changes bundled in
------------------------
* Switch RNG from `rand` (0.10) to `getrandom` (0.3). We only need
OS-provided random bytes for the nonce prefix; pulling in the full
`rand` crate for one `OsRng.fill_bytes` call was overkill, and
`rand` 0.10's `OsRng` API churn makes `getrandom` the cleaner fit.
* `FcryError` gains a `Format(String)` variant for header / framing
errors and a `From<getrandom::Error>` impl (replacing the
`rand::Error` impl).
* Drop the noisy `[reader]` / `[encrypt]` / `[decrypt]` stderr
tracing prints and the `dbg!(&cli.raw_key)` (which leaked the key
to stderr).
* Replace `unwrap()` on file open / create with `?` so I/O errors
surface as structured `FcryError::Io` instead of aborting.
* Remove the unused `AheadReader::read_exact` wrapper -- the
decryptor now reads the header through the underlying `BufRead`
directly before wrapping it in `AheadReader`.
Tests
-----
Add `tests/roundtrip.rs` (assert_cmd + tempfile) covering: empty
input, single byte, sub-chunk, exact chunk, chunk+1, multi-chunk,
custom small chunk size (4096), pathological 1-byte chunk size,
stdin/stdout pipe mode, wrong key rejection, tampered header,
tampered ciphertext, truncated ciphertext, bad magic, short raw key,
and the header-is-authoritative property (encrypt with a weird chunk
size, decrypt without specifying one). Also adds a unit test in
`header.rs` for header encode/decode roundtrip and bad-magic rejection.
TODO.md trimmed to the concrete follow-up sequence (manual STREAM
nonces, secrets/rlimit, atomic output, argon2id KDF + prompt,
multi-threaded pipeline, length-committed mode).
Test plan
---------
* `cargo clippy && cargo clippy --tests` -- clean.
* `cargo +nightly fmt` -- no diff.
* `cargo test` -- 16 integration + 2 header unit tests pass.
* Manual: `echo hi | fcry --raw-key 0123456789abcdef0123456789abcdef
| fcry -d --raw-key 0123456789abcdef0123456789abcdef` prints `hi`.
Trailers
--------
Refs: TODO.md (Roadmap 1.0 follow-up sequence)
Breaking-Change: file format; 0.9.x files cannot be decrypted by 0.10.x