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:
+17
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user