Files
fcry/src/header.rs
T
ddidderr 4eee8e7a95 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
2026-05-02 17:22:47 +02:00

187 lines
5.1 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-only
//! On-disk file format for fcry.
//!
//! Layout:
//! ```text
//! magic "fcry" 4 bytes
//! version u8 1
//! alg_id u8 1
//! flags u8 1
//! reserved u8 1 (must be 0)
//! chunk_size u32 LE 4 (plaintext bytes per chunk)
//! kdf_id u8 1
//! kdf_params variable (depends on kdf_id)
//! nonce_prefix [u8; 19] 19 (STREAM nonce prefix)
//! --- end of header ---
//! chunk[0..N] each chunk_size + 16 bytes,
//! last may be shorter
//! ```
//!
//! The full encoded header is fed as AAD to every chunk, so any tampering
//! with chunk_size, nonce_prefix, kdf params, etc. causes authentication
//! failure on every chunk.
use std::io::Read;
use crate::error::FcryError;
const MAGIC: [u8; 4] = *b"fcry";
const VERSION: u8 = 1;
pub const NONCE_PREFIX_LEN: usize = 19;
pub const TAG_LEN: usize = 16;
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AlgId {
XChaCha20Poly1305 = 1,
}
impl AlgId {
fn from_u8(v: u8) -> Result<Self, FcryError> {
match v {
1 => Ok(Self::XChaCha20Poly1305),
_ => Err(FcryError::Format(format!("unknown alg id: {v}"))),
}
}
}
/// Key-derivation parameters stored in the header.
///
/// `Raw` means the key was supplied directly (no KDF). Future variants
/// (e.g. Argon2id) will carry their salt + cost parameters here.
#[derive(Clone, Debug)]
pub enum KdfParams {
Raw,
}
impl KdfParams {
fn id(&self) -> u8 {
match self {
Self::Raw => 0,
}
}
fn write_into(&self, _out: &mut Vec<u8>) {
match self {
Self::Raw => {}
}
}
fn read_from(id: u8, _r: &mut impl Read) -> Result<Self, FcryError> {
match id {
0 => Ok(Self::Raw),
_ => Err(FcryError::Format(format!("unknown kdf id: {id}"))),
}
}
}
#[derive(Clone, Debug)]
pub struct Header {
pub alg: AlgId,
pub flags: u8,
pub chunk_size: u32,
pub kdf: KdfParams,
pub nonce_prefix: [u8; NONCE_PREFIX_LEN],
}
impl Header {
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(64);
out.extend_from_slice(&MAGIC);
out.push(VERSION);
out.push(self.alg as u8);
out.push(self.flags);
out.push(0); // reserved
out.extend_from_slice(&self.chunk_size.to_le_bytes());
out.push(self.kdf.id());
self.kdf.write_into(&mut out);
out.extend_from_slice(&self.nonce_prefix);
out
}
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
let mut magic = [0u8; 4];
r.read_exact(&mut magic)?;
if magic != MAGIC {
return Err(FcryError::Format("not an fcry file (bad magic)".into()));
}
let mut fixed = [0u8; 4];
r.read_exact(&mut fixed)?;
let [version, alg_id, flags, reserved] = fixed;
if version != VERSION {
return Err(FcryError::Format(format!("unsupported version: {version}")));
}
if reserved != 0 {
return Err(FcryError::Format("reserved byte must be zero".into()));
}
let alg = AlgId::from_u8(alg_id)?;
let mut chunk_size_bytes = [0u8; 4];
r.read_exact(&mut chunk_size_bytes)?;
let chunk_size = u32::from_le_bytes(chunk_size_bytes);
if chunk_size == 0 {
return Err(FcryError::Format("chunk_size must be > 0".into()));
}
let mut kdf_id = [0u8; 1];
r.read_exact(&mut kdf_id)?;
let kdf = KdfParams::read_from(kdf_id[0], r)?;
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
r.read_exact(&mut nonce_prefix)?;
Ok(Self {
alg,
flags,
chunk_size,
kdf,
nonce_prefix,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn roundtrip() {
let h = Header {
alg: AlgId::XChaCha20Poly1305,
flags: 0,
chunk_size: 1024 * 1024,
kdf: KdfParams::Raw,
nonce_prefix: [7u8; NONCE_PREFIX_LEN],
};
let bytes = h.encode();
let mut cur = Cursor::new(&bytes);
let parsed = Header::read(&mut cur).unwrap();
assert_eq!(parsed.alg, h.alg);
assert_eq!(parsed.flags, h.flags);
assert_eq!(parsed.chunk_size, h.chunk_size);
assert_eq!(parsed.nonce_prefix, h.nonce_prefix);
assert_eq!(cur.position() as usize, bytes.len());
}
#[test]
fn rejects_bad_magic() {
let mut bytes = Header {
alg: AlgId::XChaCha20Poly1305,
flags: 0,
chunk_size: 4096,
kdf: KdfParams::Raw,
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
}
.encode();
bytes[0] ^= 1;
assert!(matches!(
Header::read(&mut Cursor::new(&bytes)),
Err(FcryError::Format(_))
));
}
}