fix(header): preserve on-disk version through decode/encode
`Header::encode()` previously hard-coded `VERSION_CURRENT` (= 2) on every write. Because the encoded header is fed back as AEAD AAD on decrypt, this broke decryption of any v1 ciphertext written before commit75afadb: the file's authenticated AAD has version byte 1, but the recomputed AAD has byte 2, so AEAD verification fails on every chunk. The release notes for75afadbexplicitly promised v1 compatibility, so this is a regression against the documented contract — caught by an external reviewer who reproduced it by encrypting with HEAD^ and decrypting with HEAD. Fix by adding a `version: u8` field to `Header`. `Header::read()` now captures the on-disk byte and `encode()` writes it back. New encrypts in `crypto::encrypt()` set `version = VERSION_CURRENT`, so freshly written files are unchanged on the wire; only the round-trip path through `read → encode` is now byte-identical for v1 inputs. This was the simplest fix that preserves the existing AAD design (header bytes verbatim → AAD). Alternatives considered: - Storing the raw header bytes alongside the parsed struct would also work but spends an extra allocation and adds a second source of truth for the same data. - Conditionally emitting v2 only when flags != 0 would happen to produce v1 bytes for a v1 input, but it conflates "what version does this file claim" with "does it carry length commitment" — two things that should stay independent for future flag bits. Test plan: - `header::tests::reads_v1_header` now also asserts that re-encoding a parsed v1 header reproduces the original bytes exactly (so the AAD round-trips). - New `crypto::tests::decrypts_v1_ciphertext` and `decrypts_v1_ciphertext_parallel` build a multi-chunk v1 fixture by hand (XChaCha20Poly1305 with version-byte-1 AAD) and confirm `decrypt()` succeeds on both the serial and parallel paths. - Manual: built `HEAD^` in a worktree, encrypted a 200-byte payload with `--chunk-size 64`, decrypted with the patched binary at `-j 1` and `-j 4`. Both round-trip; output bytes match input. Refs: external review identifying this regression.
This commit is contained in:
+16
-2
@@ -34,7 +34,7 @@ use std::io::Read;
|
||||
use crate::error::FcryError;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"fcry";
|
||||
const VERSION_CURRENT: u8 = 2;
|
||||
pub const VERSION_CURRENT: u8 = 2;
|
||||
const VERSION_MIN: u8 = 1;
|
||||
|
||||
pub const NONCE_PREFIX_LEN: usize = 19;
|
||||
@@ -131,6 +131,10 @@ impl KdfParams {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Header {
|
||||
/// On-disk format version. Set to `VERSION_CURRENT` for new encrypts;
|
||||
/// preserved as-read for decrypt so the AAD recomputed on decode matches
|
||||
/// the bytes that were authenticated when the file was written.
|
||||
pub version: u8,
|
||||
pub alg: AlgId,
|
||||
pub flags: u8,
|
||||
pub chunk_size: u32,
|
||||
@@ -144,7 +148,7 @@ impl Header {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(72);
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.push(VERSION_CURRENT);
|
||||
out.push(self.version);
|
||||
out.push(self.alg as u8);
|
||||
out.push(self.flags);
|
||||
out.push(0); // reserved
|
||||
@@ -210,6 +214,7 @@ impl Header {
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
alg,
|
||||
flags,
|
||||
chunk_size,
|
||||
@@ -228,6 +233,7 @@ mod tests {
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let h = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: 0,
|
||||
chunk_size: 1024 * 1024,
|
||||
@@ -238,6 +244,7 @@ mod tests {
|
||||
let bytes = h.encode();
|
||||
let mut cur = Cursor::new(&bytes);
|
||||
let parsed = Header::read(&mut cur).unwrap();
|
||||
assert_eq!(parsed.version, h.version);
|
||||
assert_eq!(parsed.alg, h.alg);
|
||||
assert_eq!(parsed.flags, h.flags);
|
||||
assert_eq!(parsed.chunk_size, h.chunk_size);
|
||||
@@ -249,6 +256,7 @@ mod tests {
|
||||
#[test]
|
||||
fn roundtrip_length_committed() {
|
||||
let h = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: FLAG_LENGTH_COMMITTED,
|
||||
chunk_size: 65536,
|
||||
@@ -267,6 +275,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let mut bytes = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: 0,
|
||||
chunk_size: 4096,
|
||||
@@ -285,6 +294,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_unknown_flag_bits() {
|
||||
let mut bytes = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: 0,
|
||||
chunk_size: 4096,
|
||||
@@ -314,8 +324,12 @@ mod tests {
|
||||
bytes.push(0); // kdf id raw
|
||||
bytes.extend_from_slice(&[3u8; NONCE_PREFIX_LEN]);
|
||||
let parsed = Header::read(&mut Cursor::new(&bytes)).unwrap();
|
||||
assert_eq!(parsed.version, 1);
|
||||
assert_eq!(parsed.flags, 0);
|
||||
assert_eq!(parsed.chunk_size, 1024);
|
||||
assert_eq!(parsed.plaintext_length, None);
|
||||
// Re-encoding must reproduce the original v1 bytes exactly so the
|
||||
// recomputed AAD matches what the file was authenticated with.
|
||||
assert_eq!(parsed.encode(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user