feat: harden fcry format and IO policy

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
This commit is contained in:
2026-06-09 23:45:02 +02:00
parent d7b0127d20
commit 81ac1475ad
12 changed files with 1625 additions and 227 deletions
+17 -15
View File
@@ -42,31 +42,33 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded};
use crate::crypto::{bump_counter, make_nonce};
use crate::error::FcryError;
use crate::header::NONCE_PREFIX_LEN;
use crate::policy;
use crate::reader::{AheadReader, ReadInfoChunk};
use crate::utils::OutSink;
use zeroize::Zeroizing;
struct Job {
counter: u32,
last: bool,
buf: Vec<u8>,
buf: Zeroizing<Vec<u8>>,
}
struct Done {
counter: u32,
buf: Vec<u8>,
buf: Zeroizing<Vec<u8>>,
}
/// Job-channel capacity: small multiples of worker count, enough to keep
/// workers fed without unbounded memory.
fn channel_capacity(threads: usize) -> usize {
(threads * 2).max(2)
fn channel_capacity(threads: usize, in_flight: usize) -> usize {
policy::pipeline_channel_capacity(threads, in_flight)
}
/// Total in-flight chunk cap (jobs queued + at workers + in writer's reorder
/// buffer). Permit count; bounded above the job-channel capacity to absorb
/// reordering without blocking workers unnecessarily.
fn in_flight_capacity(threads: usize) -> usize {
(threads * 4).max(4)
fn in_flight_capacity(threads: usize, chunk_len: usize) -> usize {
policy::pipeline_in_flight_capacity(threads, chunk_len)
}
#[allow(clippy::too_many_arguments)]
@@ -152,8 +154,8 @@ fn run_pipeline(
threads: usize,
is_encrypt: bool,
) -> Result<(OutSink, u64), FcryError> {
let cap = channel_capacity(threads);
let in_flight = in_flight_capacity(threads);
let in_flight = in_flight_capacity(threads, chunk_sz);
let cap = channel_capacity(threads, in_flight);
let (jobs_tx, jobs_rx) = bounded::<Job>(cap);
let (done_tx, done_rx) = bounded::<Done>(cap);
@@ -193,7 +195,7 @@ fn run_pipeline(
Err(RecvTimeoutError::Disconnected) => return Ok(bytes_seen),
}
}
let mut buf = vec![0u8; chunk_sz];
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
match input.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => {
if jobs_tx
@@ -206,7 +208,7 @@ fn run_pipeline(
{
return Ok(bytes_seen);
}
bytes_seen = bytes_seen.saturating_add(chunk_sz as u64);
bytes_seen = policy::checked_count_add(bytes_seen, chunk_sz, "bytes read")?;
counter = bump_counter(counter)?;
}
ReadInfoChunk::Last(n) => {
@@ -216,7 +218,7 @@ fn run_pipeline(
last: true,
buf,
});
bytes_seen = bytes_seen.saturating_add(n as u64);
bytes_seen = policy::checked_count_add(bytes_seen, n, "bytes read")?;
return Ok(bytes_seen);
}
ReadInfoChunk::Empty => {
@@ -261,9 +263,9 @@ fn run_pipeline(
}
let nonce = make_nonce(&nonce_prefix, job.counter, job.last);
let res = if is_encrypt {
aead.encrypt_in_place(&nonce, aad.as_slice(), &mut job.buf)
aead.encrypt_in_place(&nonce, aad.as_slice(), &mut *job.buf)
} else {
aead.decrypt_in_place(&nonce, aad.as_slice(), &mut job.buf)
aead.decrypt_in_place(&nonce, aad.as_slice(), &mut *job.buf)
};
if let Err(e) = res {
cancel.store(true, Ordering::Release);
@@ -343,13 +345,13 @@ fn ordered_writer(
permit_tx: Sender<()>,
) -> Result<(OutSink, u64), FcryError> {
let mut next: u32 = 0;
let mut pending: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
let mut pending: BTreeMap<u32, Zeroizing<Vec<u8>>> = BTreeMap::new();
let mut total: u64 = 0;
for done in done_rx.iter() {
pending.insert(done.counter, done.buf);
while let Some(buf) = pending.remove(&next) {
output.write_all(&buf)?;
total = total.saturating_add(buf.len() as u64);
total = policy::checked_count_add(total, buf.len(), "bytes written")?;
// `bump_counter` rejects overflow upstream; a wrap here would be
// a real bug, so use plain addition and let it panic in debug.
next += 1;