feat!: add file-format header, configurable chunks, integration tests
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
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
//
|
||||
// Integration tests for the `fcry` binary.
|
||||
//
|
||||
// These exercise the CLI as a black box: encrypt then decrypt and check that
|
||||
// plaintext bytes are preserved, plus a handful of failure cases (tampering,
|
||||
// wrong key, truncation, bad magic).
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
|
||||
const KEY_STR: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn fcry() -> Command {
|
||||
Command::cargo_bin("fcry").unwrap()
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random plaintext of `n` bytes (xorshift, seedable).
|
||||
/// We avoid `/dev/urandom` so tests are reproducible on failure.
|
||||
fn pseudo_random(seed: u64, n: usize) -> Vec<u8> {
|
||||
let mut s = seed.wrapping_add(0x9E3779B97F4A7C15);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
while out.len() < n {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
out.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
out.truncate(n);
|
||||
out
|
||||
}
|
||||
|
||||
fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option<u32>) {
|
||||
let mut cmd = fcry();
|
||||
cmd.arg("-i")
|
||||
.arg(plain)
|
||||
.arg("-o")
|
||||
.arg(ct)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR);
|
||||
if let Some(cs) = chunk_size {
|
||||
cmd.arg("--chunk-size").arg(cs.to_string());
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"encrypt failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) {
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(ct)
|
||||
.arg("-o")
|
||||
.arg(rt)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"decrypt failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn roundtrip_with_size(plaintext_size: usize, chunk_size: Option<u32>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("plain.bin");
|
||||
let ct = dir.path().join("ct.bin");
|
||||
let rt = dir.path().join("rt.bin");
|
||||
|
||||
let data = pseudo_random(plaintext_size as u64, plaintext_size);
|
||||
fs::write(&plain, &data).unwrap();
|
||||
|
||||
encrypt_file(&plain, &ct, chunk_size);
|
||||
decrypt_file(&ct, &rt);
|
||||
|
||||
let got = fs::read(&rt).unwrap();
|
||||
assert_eq!(got, data, "roundtrip mismatch at size {plaintext_size}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_empty() {
|
||||
roundtrip_with_size(0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_one_byte() {
|
||||
roundtrip_with_size(1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_smaller_than_chunk() {
|
||||
roundtrip_with_size(100, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_exactly_one_chunk() {
|
||||
roundtrip_with_size(1024 * 1024, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_just_over_one_chunk() {
|
||||
roundtrip_with_size(1024 * 1024 + 1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_multi_chunk() {
|
||||
roundtrip_with_size(5 * 1024 * 1024 + 12345, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_custom_small_chunk_size() {
|
||||
// forces many chunks for a small input
|
||||
roundtrip_with_size(50_000, Some(4096));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_chunk_size_one_byte() {
|
||||
// pathological but should still work
|
||||
roundtrip_with_size(257, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_pipe_stdin_stdout() {
|
||||
let data = pseudo_random(42, 200_000);
|
||||
|
||||
let mut enc = fcry()
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
enc.stdin.as_mut().unwrap().write_all(&data).unwrap();
|
||||
let enc_out = enc.wait_with_output().unwrap();
|
||||
assert!(
|
||||
enc_out.status.success(),
|
||||
"pipe encrypt failed: {}",
|
||||
String::from_utf8_lossy(&enc_out.stderr)
|
||||
);
|
||||
|
||||
let mut dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
dec.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(&enc_out.stdout)
|
||||
.unwrap();
|
||||
let dec_out = dec.wait_with_output().unwrap();
|
||||
assert!(
|
||||
dec_out.status.success(),
|
||||
"pipe decrypt failed: {}",
|
||||
String::from_utf8_lossy(&dec_out.stderr)
|
||||
);
|
||||
|
||||
assert_eq!(dec_out.stdout, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, pseudo_random(1, 1000)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
let wrong = "ffffffffffffffffffffffffffffffff";
|
||||
assert_ne!(wrong.as_bytes(), KEY);
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(wrong)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "decrypt with wrong key should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_header() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, pseudo_random(2, 1000)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
// Flip a byte in the chunk_size field of the header (offset 8: 4 magic + 4 fixed).
|
||||
let mut bytes = fs::read(&ct).unwrap();
|
||||
bytes[8] ^= 0xff;
|
||||
fs::write(&ct, &bytes).unwrap();
|
||||
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"decrypt with tampered header should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_ciphertext() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, pseudo_random(3, 5000)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
// Flip a byte well past the header (in the first ciphertext chunk).
|
||||
let mut bytes = fs::read(&ct).unwrap();
|
||||
let off = bytes.len() / 2;
|
||||
bytes[off] ^= 0x01;
|
||||
fs::write(&ct, &bytes).unwrap();
|
||||
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"decrypt of tampered ciphertext should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_ciphertext() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, pseudo_random(4, 3 * 1024 * 1024)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
// Drop the trailing 16-byte tag of the last chunk (and then some).
|
||||
let mut bytes = fs::read(&ct).unwrap();
|
||||
bytes.truncate(bytes.len() - 32);
|
||||
fs::write(&ct, &bytes).unwrap();
|
||||
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"decrypt of truncated ciphertext should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bogus = dir.path().join("bogus.bin");
|
||||
fs::write(&bogus, b"NOPE\x01\x01\x00\x00\x00\x10\x00\x00\x00").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&bogus)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"decrypt of file with bad magic should fail"
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("magic"),
|
||||
"expected 'magic' in stderr, got: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_raw_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("c.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg("tooshort")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"encrypt with short raw_key should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_chunk_size_is_authoritative_on_decrypt() {
|
||||
// Encrypt with a non-default chunk size; decrypt without specifying one.
|
||||
// The decryptor must read chunk_size from the header.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
let rt = dir.path().join("r.bin");
|
||||
let data = pseudo_random(5, 100_000);
|
||||
fs::write(&plain, &data).unwrap();
|
||||
encrypt_file(&plain, &ct, Some(7919)); // prime, deliberately weird
|
||||
decrypt_file(&ct, &rt);
|
||||
assert_eq!(fs::read(&rt).unwrap(), data);
|
||||
}
|
||||
Reference in New Issue
Block a user