feat!: multi-threaded pipeline + length-committed/random-access decrypt
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>
This commit is contained in:
+115
-17
@@ -4,34 +4,49 @@
|
||||
//!
|
||||
//! 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)
|
||||
//! 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)
|
||||
//! plaintext_length u64 LE 8 (only if version >= 2 and flags & 0x01)
|
||||
//! --- end of header ---
|
||||
//! chunk[0..N] each chunk_size + 16 bytes,
|
||||
//! last may be shorter
|
||||
//! 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.
|
||||
//! with chunk_size, nonce_prefix, kdf params, plaintext_length, etc. causes
|
||||
//! authentication failure on every chunk.
|
||||
//!
|
||||
//! Versions:
|
||||
//! * v1 — no length committed, no flag bits used.
|
||||
//! * v2 — adds `FLAG_LENGTH_COMMITTED` (bit 0); when set, the total plaintext
|
||||
//! length is appended after `nonce_prefix`. This enables random-access
|
||||
//! decryption without scanning predecessors.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::error::FcryError;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"fcry";
|
||||
const VERSION: u8 = 1;
|
||||
const VERSION_CURRENT: u8 = 2;
|
||||
const VERSION_MIN: u8 = 1;
|
||||
|
||||
pub const NONCE_PREFIX_LEN: usize = 19;
|
||||
pub const TAG_LEN: usize = 16;
|
||||
|
||||
/// Set in `flags` when the header carries an authenticated `plaintext_length`
|
||||
/// field. Required for random-access decryption.
|
||||
pub const FLAG_LENGTH_COMMITTED: u8 = 0x01;
|
||||
|
||||
/// Mask of all flag bits this build understands. Unknown bits → reject.
|
||||
const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED;
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AlgId {
|
||||
@@ -121,13 +136,15 @@ pub struct Header {
|
||||
pub chunk_size: u32,
|
||||
pub kdf: KdfParams,
|
||||
pub nonce_prefix: [u8; NONCE_PREFIX_LEN],
|
||||
/// Total plaintext byte count. `Some` iff `flags & FLAG_LENGTH_COMMITTED`.
|
||||
pub plaintext_length: Option<u64>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(64);
|
||||
let mut out = Vec::with_capacity(72);
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.push(VERSION);
|
||||
out.push(VERSION_CURRENT);
|
||||
out.push(self.alg as u8);
|
||||
out.push(self.flags);
|
||||
out.push(0); // reserved
|
||||
@@ -135,6 +152,12 @@ impl Header {
|
||||
out.push(self.kdf.id());
|
||||
self.kdf.write_into(&mut out);
|
||||
out.extend_from_slice(&self.nonce_prefix);
|
||||
if (self.flags & FLAG_LENGTH_COMMITTED) != 0 {
|
||||
let len = self
|
||||
.plaintext_length
|
||||
.expect("FLAG_LENGTH_COMMITTED set but plaintext_length is None");
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -148,12 +171,20 @@ impl Header {
|
||||
let mut fixed = [0u8; 4];
|
||||
r.read_exact(&mut fixed)?;
|
||||
let [version, alg_id, flags, reserved] = fixed;
|
||||
if version != VERSION {
|
||||
if !(VERSION_MIN..=VERSION_CURRENT).contains(&version) {
|
||||
return Err(FcryError::Format(format!("unsupported version: {version}")));
|
||||
}
|
||||
if reserved != 0 {
|
||||
return Err(FcryError::Format("reserved byte must be zero".into()));
|
||||
}
|
||||
if (flags & !FLAG_KNOWN_MASK) != 0 {
|
||||
return Err(FcryError::Format(format!(
|
||||
"unknown flag bits: 0x{flags:02x}"
|
||||
)));
|
||||
}
|
||||
if version < 2 && flags != 0 {
|
||||
return Err(FcryError::Format("v1 header must have flags == 0".into()));
|
||||
}
|
||||
let alg = AlgId::from_u8(alg_id)?;
|
||||
|
||||
let mut chunk_size_bytes = [0u8; 4];
|
||||
@@ -170,12 +201,21 @@ impl Header {
|
||||
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||
r.read_exact(&mut nonce_prefix)?;
|
||||
|
||||
let plaintext_length = if (flags & FLAG_LENGTH_COMMITTED) != 0 {
|
||||
let mut b = [0u8; 8];
|
||||
r.read_exact(&mut b)?;
|
||||
Some(u64::from_le_bytes(b))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
alg,
|
||||
flags,
|
||||
chunk_size,
|
||||
kdf,
|
||||
nonce_prefix,
|
||||
plaintext_length,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,6 +233,7 @@ mod tests {
|
||||
chunk_size: 1024 * 1024,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [7u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
};
|
||||
let bytes = h.encode();
|
||||
let mut cur = Cursor::new(&bytes);
|
||||
@@ -201,6 +242,25 @@ mod tests {
|
||||
assert_eq!(parsed.flags, h.flags);
|
||||
assert_eq!(parsed.chunk_size, h.chunk_size);
|
||||
assert_eq!(parsed.nonce_prefix, h.nonce_prefix);
|
||||
assert_eq!(parsed.plaintext_length, None);
|
||||
assert_eq!(cur.position() as usize, bytes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_length_committed() {
|
||||
let h = Header {
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: FLAG_LENGTH_COMMITTED,
|
||||
chunk_size: 65536,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [9u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: Some(123_456_789),
|
||||
};
|
||||
let bytes = h.encode();
|
||||
let mut cur = Cursor::new(&bytes);
|
||||
let parsed = Header::read(&mut cur).unwrap();
|
||||
assert_eq!(parsed.flags, FLAG_LENGTH_COMMITTED);
|
||||
assert_eq!(parsed.plaintext_length, Some(123_456_789));
|
||||
assert_eq!(cur.position() as usize, bytes.len());
|
||||
}
|
||||
|
||||
@@ -212,6 +272,7 @@ mod tests {
|
||||
chunk_size: 4096,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
}
|
||||
.encode();
|
||||
bytes[0] ^= 1;
|
||||
@@ -220,4 +281,41 @@ mod tests {
|
||||
Err(FcryError::Format(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_flag_bits() {
|
||||
let mut bytes = Header {
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: 0,
|
||||
chunk_size: 4096,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
}
|
||||
.encode();
|
||||
// flags byte is at offset 6 (4 magic + version + alg)
|
||||
bytes[6] = 0x80;
|
||||
assert!(matches!(
|
||||
Header::read(&mut Cursor::new(&bytes)),
|
||||
Err(FcryError::Format(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_v1_header() {
|
||||
// hand-crafted v1 header (raw kdf, no length field)
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"fcry");
|
||||
bytes.push(1); // version
|
||||
bytes.push(1); // alg
|
||||
bytes.push(0); // flags
|
||||
bytes.push(0); // reserved
|
||||
bytes.extend_from_slice(&1024u32.to_le_bytes());
|
||||
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.flags, 0);
|
||||
assert_eq!(parsed.chunk_size, 1024);
|
||||
assert_eq!(parsed.plaintext_length, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user