diff --git a/Cargo.lock b/Cargo.lock index 818477a..ee207b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,10 +76,22 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert_cmd" version = "2.2.2" @@ -116,6 +128,20 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -136,6 +162,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -150,7 +186,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -223,6 +259,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -232,6 +274,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -303,18 +354,27 @@ version = "0.10.0" dependencies = [ "argon2", "assert_cmd", + "blake3", "chacha20poly1305", "clap", "crossbeam-channel", "getrandom 0.4.2", "libc", "rlimit", + "same-file", "secrets", "tempfile", + "unicode-normalization", "windows-sys", "zeroize", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" @@ -496,7 +556,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -599,6 +659,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "secrets" version = "1.3.0" @@ -659,6 +728,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "strsim" version = "0.11.1" @@ -701,6 +776,21 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.0" @@ -713,6 +803,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-xid" version = "0.2.6" @@ -830,6 +929,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 2086a42..b4b24ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,10 @@ chacha20poly1305 = "0.10" clap = { version = "4", features = ["derive"] } crossbeam-channel = "0.5" getrandom = { version = "0.4" } +blake3 = "1" protected-secrets = { package = "secrets", version = "1.3" } +same-file = "1" +unicode-normalization = "0.1" zeroize = { version = "1", features = ["derive"] } [dev-dependencies] diff --git a/src/crypto.rs b/src/crypto.rs index 5ac202a..c666b57 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -7,12 +7,15 @@ use std::sync::Arc; use crate::error::*; use crate::header::{ - AlgId, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN, VERSION_CURRENT, + AlgId, FLAG_KEY_COMMITTED, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN, + VERSION_CURRENT, }; use crate::pipeline; +use crate::policy; use crate::reader::{AheadReader, ReadInfoChunk}; use crate::secrets::{SecretBytes32, SecretVec}; use crate::utils::*; +use zeroize::Zeroizing; /// XChaCha20Poly1305 nonce: 24 bytes total. STREAM splits the trailing 5 bytes /// into a 4-byte big-endian counter and a 1-byte "last block" flag. @@ -40,7 +43,7 @@ pub fn derive_key( match kdf { KdfParams::Raw => { let raw = - raw_key.ok_or_else(|| FcryError::Format("raw kdf requires --raw-key".into()))?; + raw_key.ok_or_else(|| FcryError::Format("raw kdf requires --key-file".into()))?; raw.with_array(|raw| out.with_mut_array(|out| out.copy_from_slice(raw))); } KdfParams::Argon2id { @@ -67,6 +70,32 @@ fn build_aead(key: &SecretBytes32) -> Arc { Arc::new(key.with_array(|key| XChaCha20Poly1305::new(key.into()))) } +fn compute_key_commitment(key: &SecretBytes32, header: &Header) -> [u8; 32] { + key.with_array(|key| { + let mut hasher = blake3::Hasher::new_keyed(key); + hasher.update(b"fcry-kcv-v3"); + hasher.update(&[0]); + hasher.update(&header.commitment_input_encoding()); + *hasher.finalize().as_bytes() + }) +} + +fn verify_key_commitment(header: &Header, key: &SecretBytes32) -> Result<(), FcryError> { + let Some(expected) = header.key_commitment else { + return Ok(()); + }; + let actual = compute_key_commitment(key, header); + let mut diff = 0u8; + for (a, b) in actual.iter().zip(expected.iter()) { + diff |= a ^ b; + } + if diff == 0 { + Ok(()) + } else { + Err(FcryError::WrongKey) + } +} + /// Bump the per-chunk counter; surface a domain error on overflow rather than /// panicking on debug or wrapping in release. pub(crate) fn bump_counter(counter: u32) -> Result { @@ -75,6 +104,7 @@ pub(crate) fn bump_counter(counter: u32) -> Result { .ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into())) } +#[allow(dead_code)] pub fn encrypt>( input_file: Option, output_file: Option, @@ -83,11 +113,31 @@ pub fn encrypt>( kdf: KdfParams, threads: usize, ) -> Result<(), FcryError> { - let chunk_sz = chunk_size as usize; + encrypt_with_output_options( + input_file, + output_file, + key, + chunk_size, + kdf, + threads, + &OutSinkOptions::default(), + ) +} + +pub fn encrypt_with_output_options>( + input_file: Option, + output_file: Option, + key: &SecretBytes32, + chunk_size: u32, + kdf: KdfParams, + threads: usize, + output_options: &OutSinkOptions, +) -> Result<(), FcryError> { + let chunk_sz = policy::validate_chunk_size(chunk_size)?; let input = open_input(input_file)?; let plaintext_length = input.length; let mut f_plain = AheadReader::from(input.reader, chunk_sz); - let mut f_encrypted = OutSink::open(output_file)?; + let mut f_encrypted = OutSink::open_with_options(output_file, output_options)?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; getrandom::fill(&mut nonce_prefix)?; @@ -96,8 +146,8 @@ pub fn encrypt>( FLAG_LENGTH_COMMITTED } else { 0 - }; - let header = Header { + } | FLAG_KEY_COMMITTED; + let mut header = Header { version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags, @@ -105,7 +155,9 @@ pub fn encrypt>( kdf, nonce_prefix, plaintext_length, + key_commitment: None, }; + header.key_commitment = Some(compute_key_commitment(key, &header)); let aad = Arc::new(header.encode()); f_encrypted.write_all(&aad)?; @@ -124,7 +176,7 @@ pub fn encrypt>( ); } - let mut buf = vec![0u8; chunk_sz]; + let mut buf = Zeroizing::new(vec![0u8; chunk_sz]); let mut counter: u32 = 0; let mut bytes_seen: u64 = 0; @@ -132,18 +184,18 @@ pub fn encrypt>( match f_plain.read_ahead(&mut buf)? { ReadInfoChunk::Normal(_) => { let nonce = make_nonce(&nonce_prefix, counter, false); - aead.encrypt_in_place(&nonce, &aad, &mut buf)?; + aead.encrypt_in_place(&nonce, &aad, &mut *buf)?; f_encrypted.write_all(&buf)?; buf.truncate(chunk_sz); - 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) => { buf.truncate(n); let nonce = make_nonce(&nonce_prefix, counter, true); - aead.encrypt_in_place(&nonce, &aad, &mut buf)?; + aead.encrypt_in_place(&nonce, &aad, &mut *buf)?; f_encrypted.write_all(&buf)?; - bytes_seen = bytes_seen.saturating_add(n as u64); + bytes_seen = policy::checked_count_add(bytes_seen, n, "bytes read")?; break; } ReadInfoChunk::Empty => { @@ -151,7 +203,7 @@ pub fn encrypt>( // authenticates the (empty) stream rather than silently producing nothing. buf.clear(); let nonce = make_nonce(&nonce_prefix, counter, true); - aead.encrypt_in_place(&nonce, &aad, &mut buf)?; + aead.encrypt_in_place(&nonce, &aad, &mut *buf)?; f_encrypted.write_all(&buf)?; break; } @@ -173,24 +225,65 @@ pub fn encrypt>( Ok(()) } +#[allow(dead_code)] pub fn decrypt>( input_file: Option, output_file: Option, raw_key: Option<&SecretBytes32>, passphrase: Option<&SecretVec>, threads: usize, +) -> Result<(), FcryError> { + decrypt_with_argon_cap( + input_file, + output_file, + raw_key, + passphrase, + threads, + policy::default_argon_decrypt_cap_mib(), + ) +} + +#[allow(dead_code)] +pub fn decrypt_with_argon_cap>( + input_file: Option, + output_file: Option, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, + threads: usize, + max_argon_memory_mib: u32, +) -> Result<(), FcryError> { + decrypt_with_output_options( + input_file, + output_file, + raw_key, + passphrase, + threads, + max_argon_memory_mib, + &OutSinkOptions::default(), + ) +} + +pub fn decrypt_with_output_options>( + input_file: Option, + output_file: Option, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, + threads: usize, + max_argon_memory_mib: u32, + output_options: &OutSinkOptions, ) -> Result<(), FcryError> { let mut reader = open_input(input_file)?.reader; - let header = Header::read(&mut reader)?; + let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?; let aad = Arc::new(header.encode()); let key = derive_key(&header.kdf, raw_key, passphrase)?; + verify_key_commitment(&header, &key)?; - let chunk_sz = header.chunk_size as usize; - let cipher_chunk = chunk_sz + TAG_LEN; + let chunk_sz = policy::validate_chunk_size(header.chunk_size)?; + let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?; let mut f_encrypted = AheadReader::from(reader, cipher_chunk); - let mut f_plain = OutSink::open(output_file)?; + let mut f_plain = OutSink::open_with_options(output_file, output_options)?; let aead = build_aead(&key); @@ -207,7 +300,7 @@ pub fn decrypt>( ); } - let mut buf = vec![0u8; cipher_chunk]; + let mut buf = Zeroizing::new(vec![0u8; cipher_chunk]); let mut counter: u32 = 0; let mut bytes_written: u64 = 0; @@ -215,18 +308,20 @@ pub fn decrypt>( match f_encrypted.read_ahead(&mut buf)? { ReadInfoChunk::Normal(_) => { let nonce = make_nonce(&header.nonce_prefix, counter, false); - aead.decrypt_in_place(&nonce, &aad, &mut buf)?; + aead.decrypt_in_place(&nonce, &aad, &mut *buf)?; f_plain.write_all(&buf)?; - bytes_written = bytes_written.saturating_add(buf.len() as u64); + bytes_written = + policy::checked_count_add(bytes_written, buf.len(), "bytes written")?; buf.resize(cipher_chunk, 0); counter = bump_counter(counter)?; } ReadInfoChunk::Last(n) => { buf.truncate(n); let nonce = make_nonce(&header.nonce_prefix, counter, true); - aead.decrypt_in_place(&nonce, &aad, &mut buf)?; + aead.decrypt_in_place(&nonce, &aad, &mut *buf)?; f_plain.write_all(&buf)?; - bytes_written = bytes_written.saturating_add(buf.len() as u64); + bytes_written = + policy::checked_count_add(bytes_written, buf.len(), "bytes written")?; break; } ReadInfoChunk::Empty => { @@ -253,6 +348,7 @@ pub fn decrypt>( /// whose header has `FLAG_LENGTH_COMMITTED` set, so we know exactly where /// each ciphertext chunk lives and which chunk is the last (its nonce uses /// the STREAM last-block flag). +#[allow(dead_code)] pub fn decrypt_range>( input_file: &str, output_file: Option, @@ -261,9 +357,56 @@ pub fn decrypt_range>( offset: u64, length: u64, ) -> Result<(), FcryError> { + decrypt_range_with_argon_cap( + input_file, + output_file, + raw_key, + passphrase, + offset, + length, + policy::default_argon_decrypt_cap_mib(), + ) +} + +#[allow(dead_code)] +pub fn decrypt_range_with_argon_cap>( + input_file: &str, + output_file: Option, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, + offset: u64, + length: u64, + max_argon_memory_mib: u32, +) -> Result<(), FcryError> { + decrypt_range_with_output_options( + input_file, + output_file, + raw_key, + passphrase, + offset, + length, + max_argon_memory_mib, + &OutSinkOptions::default(), + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn decrypt_range_with_output_options>( + input_file: &str, + output_file: Option, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, + offset: u64, + length: u64, + max_argon_memory_mib: u32, + output_options: &OutSinkOptions, +) -> Result<(), FcryError> { + if length == 0 { + return Err(FcryError::Format("--length 0 is not allowed".into())); + } let file = File::open(input_file)?; let mut reader = BufReader::new(file); - let header = Header::read(&mut reader)?; + let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?; let aad = header.encode(); let header_len = aad.len() as u64; @@ -283,10 +426,13 @@ pub fn decrypt_range>( } let key = derive_key(&header.kdf, raw_key, passphrase)?; + verify_key_commitment(&header, &key)?; let aead = build_aead(&key); - let chunk_sz = header.chunk_size as u64; - let cipher_chunk = chunk_sz + TAG_LEN as u64; + let chunk_sz_usize = policy::validate_chunk_size(header.chunk_size)?; + let cipher_chunk_usize = policy::cipher_chunk_len(chunk_sz_usize)?; + let chunk_sz = chunk_sz_usize as u64; + let cipher_chunk = cipher_chunk_usize as u64; // Layout invariants: // n_chunks = ceil(total / chunk_sz), but always ≥ 1 (the empty file @@ -297,23 +443,21 @@ pub fn decrypt_range>( (1u64, 0u64) } else { let n = total.div_ceil(chunk_sz); - let last = total - (n - 1) * chunk_sz; + let before_last = policy::checked_mul_u64(n - 1, chunk_sz, "last chunk offset")?; + let last = total + .checked_sub(before_last) + .ok_or_else(|| FcryError::Format("last chunk length underflow".into()))?; (n, last) }; let last_idx = n_chunks - 1; - let mut out = OutSink::open(output_file)?; - - if length == 0 { - out.commit()?; - return Ok(()); - } + let mut out = OutSink::open_with_options(output_file, output_options)?; let start_chunk = offset / chunk_sz; let end_chunk = (end - 1) / chunk_sz; // Reusable buffer sized to a full chunk + tag. - let mut buf = Vec::with_capacity(cipher_chunk as usize); + let mut buf = Zeroizing::new(Vec::with_capacity(cipher_chunk_usize)); let mut file = reader.into_inner(); @@ -329,19 +473,23 @@ pub fn decrypt_range>( let cipher_len_usz = usize::try_from(cipher_len).map_err(|_| FcryError::Format("chunk too big".into()))?; - let chunk_offset = header_len + i * cipher_chunk; + let chunk_offset = policy::checked_add_u64( + header_len, + policy::checked_mul_u64(i, cipher_chunk, "ciphertext chunk offset")?, + "ciphertext chunk offset", + )?; file.seek(SeekFrom::Start(chunk_offset))?; buf.clear(); buf.resize(cipher_len_usz, 0); file.read_exact(&mut buf)?; let nonce = make_nonce(&header.nonce_prefix, i_u32, is_last); - aead.decrypt_in_place(&nonce, &aad, &mut buf)?; + aead.decrypt_in_place(&nonce, &aad, &mut *buf)?; // `buf` is now plaintext for this chunk. Compute the chunk's plaintext // window in absolute bytes and intersect with the requested range. - let chunk_start = i * chunk_sz; - let chunk_end = chunk_start + buf.len() as u64; + let chunk_start = policy::checked_mul_u64(i, chunk_sz, "plaintext chunk offset")?; + let chunk_end = policy::checked_count_add(chunk_start, buf.len(), "plaintext chunk end")?; let lo = offset.max(chunk_start) - chunk_start; let hi = end.min(chunk_end) - chunk_start; out.write_all(&buf[lo as usize..hi as usize])?; @@ -375,6 +523,7 @@ mod tests { kdf: KdfParams::Raw, nonce_prefix, plaintext_length: None, + key_commitment: None, }; let aad = header.encode(); // First byte after MAGIC is the version — verify our fixture really diff --git a/src/error.rs b/src/error.rs index 4d34704..92589c1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,7 @@ pub enum FcryError { Format(String), Kdf(String), Passphrase(String), + WrongKey, } impl From for FcryError { diff --git a/src/header.rs b/src/header.rs index cd9fa06..9413284 100644 --- a/src/header.rs +++ b/src/header.rs @@ -14,6 +14,7 @@ //! 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) +//! key_commitment [u8; 32] 32 (only if version >= 3 and flags & 0x02) //! --- end of header --- //! chunk[0..N] each chunk_size + 16 bytes, //! last may be shorter @@ -28,13 +29,16 @@ //! * 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. +//! * v3 — adds `FLAG_KEY_COMMITTED` (bit 1) and an authenticated key +//! commitment for fast wrong-key detection before chunk processing. use std::io::Read; use crate::error::FcryError; +use crate::policy; const MAGIC: [u8; 4] = *b"fcry"; -pub const VERSION_CURRENT: u8 = 2; +pub const VERSION_CURRENT: u8 = 3; const VERSION_MIN: u8 = 1; pub const NONCE_PREFIX_LEN: usize = 19; @@ -43,9 +47,11 @@ 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; +pub const FLAG_KEY_COMMITTED: u8 = 0x02; /// Mask of all flag bits this build understands. Unknown bits → reject. -const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED; +const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED; +pub const KEY_COMMITMENT_LEN: usize = 32; #[repr(u8)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -142,11 +148,13 @@ pub struct Header { pub nonce_prefix: [u8; NONCE_PREFIX_LEN], /// Total plaintext byte count. `Some` iff `flags & FLAG_LENGTH_COMMITTED`. pub plaintext_length: Option, + /// v3 key commitment. `Some` iff `flags & FLAG_KEY_COMMITTED`. + pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>, } impl Header { - pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(72); + fn encode_without_commitment(&self) -> Vec { + let mut out = Vec::with_capacity(104); out.extend_from_slice(&MAGIC); out.push(self.version); out.push(self.alg as u8); @@ -165,7 +173,30 @@ impl Header { out } + pub fn encode(&self) -> Vec { + let mut out = self.encode_without_commitment(); + if (self.flags & FLAG_KEY_COMMITTED) != 0 { + let commitment = self + .key_commitment + .expect("FLAG_KEY_COMMITTED set but key_commitment is None"); + out.extend_from_slice(&commitment); + } + out + } + + pub fn commitment_input_encoding(&self) -> Vec { + self.encode_without_commitment() + } + + #[allow(dead_code)] pub fn read(r: &mut impl Read) -> Result { + Self::read_with_argon_cap(r, policy::default_argon_decrypt_cap_mib()) + } + + pub fn read_with_argon_cap( + r: &mut impl Read, + max_argon_memory_mib: u32, + ) -> Result { let mut magic = [0u8; 4]; r.read_exact(&mut magic)?; if magic != MAGIC { @@ -189,18 +220,25 @@ impl Header { if version < 2 && flags != 0 { return Err(FcryError::Format("v1 header must have flags == 0".into())); } + if version < 3 && (flags & FLAG_KEY_COMMITTED) != 0 { + return Err(FcryError::Format( + "key commitment flag requires v3 header".into(), + )); + } + if version >= 3 && (flags & FLAG_KEY_COMMITTED) == 0 { + return Err(FcryError::Format("v3 header must commit the key".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())); - } + policy::validate_chunk_size(chunk_size)?; let mut kdf_id = [0u8; 1]; r.read_exact(&mut kdf_id)?; let kdf = KdfParams::read_from(kdf_id[0], r)?; + policy::validate_header_kdf(&kdf, max_argon_memory_mib)?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; r.read_exact(&mut nonce_prefix)?; @@ -213,6 +251,14 @@ impl Header { None }; + let key_commitment = if (flags & FLAG_KEY_COMMITTED) != 0 { + let mut b = [0u8; KEY_COMMITMENT_LEN]; + r.read_exact(&mut b)?; + Some(b) + } else { + None + }; + Ok(Self { version, alg, @@ -221,6 +267,7 @@ impl Header { kdf, nonce_prefix, plaintext_length, + key_commitment, }) } } @@ -235,11 +282,12 @@ mod tests { let h = Header { version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, - flags: 0, + flags: FLAG_KEY_COMMITTED, chunk_size: 1024 * 1024, kdf: KdfParams::Raw, nonce_prefix: [7u8; NONCE_PREFIX_LEN], plaintext_length: None, + key_commitment: Some([1u8; KEY_COMMITMENT_LEN]), }; let bytes = h.encode(); let mut cur = Cursor::new(&bytes); @@ -250,6 +298,7 @@ mod tests { assert_eq!(parsed.chunk_size, h.chunk_size); assert_eq!(parsed.nonce_prefix, h.nonce_prefix); assert_eq!(parsed.plaintext_length, None); + assert_eq!(parsed.key_commitment, h.key_commitment); assert_eq!(cur.position() as usize, bytes.len()); } @@ -258,20 +307,49 @@ mod tests { let h = Header { version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, - flags: FLAG_LENGTH_COMMITTED, + flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED, chunk_size: 65536, kdf: KdfParams::Raw, nonce_prefix: [9u8; NONCE_PREFIX_LEN], plaintext_length: Some(123_456_789), + key_commitment: Some([2u8; KEY_COMMITMENT_LEN]), }; 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.flags, FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED); assert_eq!(parsed.plaintext_length, Some(123_456_789)); + assert_eq!(parsed.key_commitment, h.key_commitment); assert_eq!(cur.position() as usize, bytes.len()); } + #[test] + fn v3_encoding_layout_stable() { + let h = Header { + version: VERSION_CURRENT, + alg: AlgId::XChaCha20Poly1305, + flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED, + chunk_size: 0x0102_0304, + kdf: KdfParams::Raw, + nonce_prefix: [0x55u8; NONCE_PREFIX_LEN], + plaintext_length: Some(0x0807_0605_0403_0201), + key_commitment: Some([0xaau8; KEY_COMMITMENT_LEN]), + }; + let commitment_input = h.commitment_input_encoding(); + assert_eq!(commitment_input.len(), 40); + assert_eq!(&commitment_input[..4], b"fcry"); + assert_eq!(commitment_input[4], 3); + assert_eq!( + &commitment_input[32..40], + &0x0807_0605_0403_0201u64.to_le_bytes() + ); + + let aad = h.encode(); + assert_eq!(aad.len(), 72); + assert_eq!(&aad[..40], &commitment_input); + assert_eq!(&aad[40..], &[0xaau8; KEY_COMMITMENT_LEN]); + } + #[test] fn rejects_bad_magic() { let mut bytes = Header { @@ -282,6 +360,7 @@ mod tests { kdf: KdfParams::Raw, nonce_prefix: [0u8; NONCE_PREFIX_LEN], plaintext_length: None, + key_commitment: Some([3u8; KEY_COMMITMENT_LEN]), } .encode(); bytes[0] ^= 1; @@ -301,6 +380,7 @@ mod tests { kdf: KdfParams::Raw, nonce_prefix: [0u8; NONCE_PREFIX_LEN], plaintext_length: None, + key_commitment: Some([4u8; KEY_COMMITMENT_LEN]), } .encode(); // flags byte is at offset 6 (4 magic + version + alg) @@ -328,6 +408,7 @@ mod tests { assert_eq!(parsed.flags, 0); assert_eq!(parsed.chunk_size, 1024); assert_eq!(parsed.plaintext_length, None); + assert_eq!(parsed.key_commitment, 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); diff --git a/src/main.rs b/src/main.rs index b69eae9..438b2fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod crypto; mod error; mod header; mod pipeline; +mod policy; mod reader; mod secrets; mod utils; @@ -12,9 +13,13 @@ use crypto::*; use error::FcryError; use header::{ARGON2_SALT_LEN, KdfParams}; use secrets::{SecretBytes32, SecretVec, read_passphrase_tty}; -use utils::DEFAULT_CHUNK_SIZE; +use utils::{DEFAULT_CHUNK_SIZE, OutSinkOptions}; use clap::Parser; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; /// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use @@ -34,10 +39,9 @@ struct Cli { #[clap(short, long)] output_file: Option, - /// The raw bytes of the crypto key. Has to be exactly 32 bytes. - /// *** DANGEROUS: visible in process listings (ps/proc). Testing only. *** - #[clap(short, long, conflicts_with_all = ["passphrase", "passphrase_env"])] - raw_key: Option>, + /// Read the raw 32-byte crypto key from a file. + #[clap(short = 'k', long, conflicts_with_all = ["passphrase", "passphrase_env"])] + key_file: Option, /// Read passphrase interactively (terminal). Implies argon2id KDF on encrypt. #[clap(short, long)] @@ -53,22 +57,43 @@ struct Cli { chunk_size: u32, /// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB). - #[clap(long, default_value_t = 1024)] + #[clap(long, default_value_t = policy::DEFAULT_ARGON_MEMORY_MIB)] argon_memory: u32, /// Argon2id passes / iterations (encryption only). - #[clap(long, default_value_t = 2)] + #[clap(long, default_value_t = policy::MIN_ARGON_PASSES)] argon_passes: u32, /// Argon2id parallelism / lanes (encryption only). - #[clap(long, default_value_t = 4)] + #[clap(long, default_value_t = policy::DEFAULT_ARGON_PARALLELISM)] argon_parallelism: u32, + /// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop. + #[clap(long)] + allow_weak_kdf: bool, + + /// Maximum Argon2id memory accepted while decrypting, in MiB. + /// Overrides the dynamic default. Raising it can OOM constrained machines. + #[clap(long)] + max_argon_memory_mib: Option, + /// Number of worker threads for AEAD work. Defaults to the number of /// available CPUs. Set to 1 for fully serial encrypt/decrypt. #[clap(short = 'j', long, value_parser = clap::value_parser!(u32).range(1..))] threads: Option, + /// Replace an existing different output file after encryption/decryption succeeds. + #[clap(long)] + force: bool, + + /// Directory for private temporary files. + #[clap(long)] + temp_dir: Option, + + /// For decrypt-to-stdout, verify the whole plaintext in a private temp file before emitting it. + #[clap(long, requires = "decrypt")] + buffer_verify: bool, + /// Random-access decrypt: byte offset of the slice to read. /// Requires `--decrypt`, an `--input-file` whose header has the /// length-committed flag set, and `--length`. @@ -91,25 +116,68 @@ struct Cli { length: Option, } -fn parse_raw_key(s: &str) -> Result { - let raw = s.as_bytes(); - if raw.len() != 32 { +fn read_key_file(path: &Path) -> Result { + warn_if_key_file_world_readable(path); + let mut file = File::open(path)?; + let mut buf = Zeroizing::new([0u8; 33]); + let n = file.read(&mut *buf)?; + if n < 32 { return Err(FcryError::Format(format!( - "raw_key must be exactly 32 bytes, got {}", - raw.len() + "key file {} is too short: expected exactly 32 bytes, got {n}", + path.display() + ))); + } + if n > 32 { + return Err(FcryError::Format(format!( + "key file {} is too long: expected exactly 32 bytes; possible trailing newline", + path.display() + ))); + } + let mut extra = Zeroizing::new([0u8; 1]); + if file.read(&mut *extra)? != 0 { + return Err(FcryError::Format(format!( + "key file {} is too long: expected exactly 32 bytes; possible trailing newline", + path.display() ))); } let mut key = SecretBytes32::zeroed(); - key.with_mut_array(|key| key.copy_from_slice(raw)); + key.with_mut_array(|key| key.copy_from_slice(&buf[..32])); Ok(key) } +#[cfg(unix)] +fn warn_if_key_file_world_readable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) { + let mode = meta.permissions().mode(); + if (mode & 0o077) != 0 { + eprintln!( + "Warning: key file {} is group/world accessible; consider chmod 600", + path.display() + ); + } + } +} + +#[cfg(not(unix))] +fn warn_if_key_file_world_readable(_path: &Path) {} + /// Source of a passphrase: either the terminal or a named env var. enum PassphraseSource { Tty, EnvVar(String), } +fn normalize_passphrase(pw: SecretVec) -> Result { + let normalized = pw.with_slice(|bytes| { + let s = std::str::from_utf8(bytes).map_err(|_| { + FcryError::Passphrase("passphrase must be valid UTF-8 after normalization".into()) + })?; + Ok::, FcryError>(Zeroizing::new(s.nfc().collect::())) + })?; + Ok(SecretVec::from_vec(normalized.as_bytes().to_vec())) +} + fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result { match src { PassphraseSource::EnvVar(var) => { @@ -117,17 +185,22 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result()); + Ok(SecretVec::from_vec(normalized.as_bytes().to_vec())) } PassphraseSource::Tty => { - let pw = read_passphrase_tty("Passphrase: ") - .map_err(|e| FcryError::Passphrase(e.to_string()))?; + let pw = normalize_passphrase( + read_passphrase_tty("Passphrase: ") + .map_err(|e| FcryError::Passphrase(e.to_string()))?, + )?; if confirm { - let pw2 = read_passphrase_tty("Confirm passphrase: ") - .map_err(|e| FcryError::Passphrase(e.to_string()))?; + let pw2 = normalize_passphrase( + read_passphrase_tty("Confirm passphrase: ") + .map_err(|e| FcryError::Passphrase(e.to_string()))?, + )?; if pw != pw2 { return Err(FcryError::Passphrase("passphrases do not match".into())); } @@ -155,7 +228,7 @@ fn disable_core_dumps() { fn run(mut cli: Cli) -> Result<(), FcryError> { // Move the secret-bearing fields out of `Cli` immediately so they don't // sit in the parsed struct for the rest of the function. - let raw_key_str: Option> = cli.raw_key.take(); + let key_file: Option = cli.key_file.take(); let pw_src: Option = if cli.passphrase { Some(PassphraseSource::Tty) } else { @@ -169,24 +242,54 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { let argon_memory = cli.argon_memory; let argon_passes = cli.argon_passes; let argon_parallelism = cli.argon_parallelism; - let threads = cli.threads.map(|n| n as usize).unwrap_or_else(|| { - std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(1) - }); + let allow_weak_kdf = cli.allow_weak_kdf; + let argon_cap = policy::resolve_argon_decrypt_cap(cli.max_argon_memory_mib)?; + if argon_cap.overridden && argon_cap.effective_mib > argon_cap.default_mib { + eprintln!( + "Warning: --max-argon-memory-mib raises the Argon2 decrypt trust ceiling from {} MiB to {} MiB; this can OOM constrained machines", + argon_cap.default_mib, argon_cap.effective_mib + ); + } + let (threads, thread_warning) = policy::normalize_worker_threads(cli.threads); + if let Some(requested) = thread_warning { + eprintln!( + "Warning: requested {requested} worker threads; capped at {}", + policy::MAX_WORKER_THREADS + ); + } + let force = cli.force; + let temp_dir = cli.temp_dir.take(); + let buffer_verify = cli.buffer_verify; let offset = cli.offset; let length = cli.length; drop(cli); - if pw_src.is_none() && raw_key_str.is_none() { + if pw_src.is_none() && key_file.is_none() { return Err(FcryError::Format( - "must provide one of --raw-key, --passphrase, --passphrase-env".into(), + "must provide one of --key-file, --passphrase, --passphrase-env".into(), + )); + } + if buffer_verify && !decrypt_mode { + return Err(FcryError::Format( + "--buffer-verify is only valid for decrypt".into(), + )); + } + if buffer_verify && output.is_some() { + return Err(FcryError::Format( + "--buffer-verify is only meaningful when decrypting to stdout".into(), )); } + let output_options = OutSinkOptions { + force, + input_file: input.as_ref().map(PathBuf::from), + temp_dir, + buffer_verify_stdout: buffer_verify, + }; + if decrypt_mode { - let raw_key = match raw_key_str.as_deref() { - Some(s) => Some(parse_raw_key(s)?), + let raw_key = match key_file.as_deref() { + Some(path) => Some(read_key_file(path)?), None => None, }; let pw = match &pw_src { @@ -202,10 +305,27 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { "--offset/--length require --input-file (random-access needs a seekable file)".into(), ) })?; - decrypt_range(path, output, raw_key.as_ref(), pw.as_ref(), o, l)?; + decrypt_range_with_output_options( + path, + output, + raw_key.as_ref(), + pw.as_ref(), + o, + l, + argon_cap.effective_mib, + &output_options, + )?; } (None, None) => { - decrypt(input, output, raw_key.as_ref(), pw.as_ref(), threads)?; + decrypt_with_output_options( + input, + output, + raw_key.as_ref(), + pw.as_ref(), + threads, + argon_cap.effective_mib, + &output_options, + )?; } _ => { return Err(FcryError::Format( @@ -217,9 +337,12 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { let (key, kdf) = if let Some(src) = &pw_src { let mut salt = [0u8; ARGON2_SALT_LEN]; getrandom::fill(&mut salt)?; - let m_cost_kib = argon_memory.checked_mul(1024).ok_or_else(|| { - FcryError::Format("argon-memory too large (overflow when converting to KiB)".into()) - })?; + let m_cost_kib = policy::validate_new_argon_params( + argon_memory, + argon_passes, + argon_parallelism, + allow_weak_kdf, + )?; let kdf = KdfParams::Argon2id { salt, m_cost: m_cost_kib, @@ -227,13 +350,22 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { p_cost: argon_parallelism, }; let pw = read_passphrase(src, true)?; + policy::validate_new_passphrase(&pw, allow_weak_kdf)?; let key = derive_key(&kdf, None, Some(&pw))?; (key, kdf) } else { - let key = parse_raw_key(raw_key_str.as_deref().unwrap())?; + let key = read_key_file(key_file.as_deref().unwrap())?; (key, KdfParams::Raw) }; - encrypt(input, output, &key, chunk_size, kdf, threads)?; + encrypt_with_output_options( + input, + output, + &key, + chunk_size, + kdf, + threads, + &output_options, + )?; } Ok(()) diff --git a/src/pipeline.rs b/src/pipeline.rs index c63f675..39b4102 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -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, + buf: Zeroizing>, } struct Done { counter: u32, - buf: Vec, + buf: Zeroizing>, } /// 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::(cap); let (done_tx, done_rx) = bounded::(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> = BTreeMap::new(); + let mut pending: BTreeMap>> = 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; diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..7c7d0eb --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Central resource and format policy. + +use std::fs; + +use crate::error::FcryError; +use crate::header::{KdfParams, TAG_LEN}; +use crate::secrets::SecretVec; + +pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024; +pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024; + +pub const DEFAULT_ARGON_MEMORY_MIB: u32 = 1024; +pub const MIN_ARGON_MEMORY_MIB: u32 = 64; +pub const DEFAULT_ARGON_DECRYPT_CAP_MIB: u32 = 4096; +pub const MIN_ARGON_PASSES: u32 = 2; +pub const MAX_ARGON_PASSES: u32 = 64; +pub const DEFAULT_ARGON_PARALLELISM: u32 = 4; +pub const MAX_ARGON_PARALLELISM: u32 = 64; +pub const MIN_PASSPHRASE_BYTES: usize = 12; + +pub const MAX_WORKER_THREADS: usize = 256; +pub const PIPELINE_IN_FLIGHT_BYTES: usize = 128 * 1024 * 1024; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ArgonDecryptCap { + pub default_mib: u32, + pub effective_mib: u32, + pub overridden: bool, +} + +pub fn architecture_argon_cap_mib() -> u32 { + let usize_cap_mib = usize::MAX / 1024 / 1024; + let argon_m_cost_cap_mib = (u32::MAX / 1024) as usize; + usize_cap_mib + .min(argon_m_cost_cap_mib) + .min(u32::MAX as usize) as u32 +} + +#[cfg(target_os = "linux")] +fn available_memory_mib() -> Option { + let meminfo = fs::read_to_string("/proc/meminfo").ok()?; + for line in meminfo.lines() { + let Some(rest) = line.strip_prefix("MemAvailable:") else { + continue; + }; + let kib = rest.split_whitespace().next()?.parse::().ok()?; + return u32::try_from(kib / 1024).ok(); + } + None +} + +#[cfg(not(target_os = "linux"))] +fn available_memory_mib() -> Option { + None +} + +pub fn default_argon_decrypt_cap_mib() -> u32 { + let mut cap = DEFAULT_ARGON_DECRYPT_CAP_MIB.min(architecture_argon_cap_mib()); + if let Some(available) = available_memory_mib() { + cap = cap.min(available); + } + cap.max(1) +} + +pub fn resolve_argon_decrypt_cap(override_mib: Option) -> Result { + let default_mib = default_argon_decrypt_cap_mib(); + let Some(effective_mib) = override_mib else { + return Ok(ArgonDecryptCap { + default_mib, + effective_mib: default_mib, + overridden: false, + }); + }; + if effective_mib == 0 { + return Err(FcryError::Format( + "--max-argon-memory-mib must be at least 1".into(), + )); + } + let arch = architecture_argon_cap_mib(); + if effective_mib > arch { + return Err(FcryError::Format(format!( + "--max-argon-memory-mib {effective_mib} exceeds this build's supported cap {arch}" + ))); + } + Ok(ArgonDecryptCap { + default_mib, + effective_mib, + overridden: true, + }) +} + +pub fn mib_to_kib(mib: u32, name: &str) -> Result { + mib.checked_mul(1024).ok_or_else(|| { + FcryError::Format(format!("{name} too large (overflow converting MiB to KiB)")) + }) +} + +pub fn validate_chunk_size(chunk_size: u32) -> Result { + if chunk_size == 0 { + return Err(FcryError::Format("chunk_size must be > 0".into())); + } + if chunk_size > MAX_CHUNK_SIZE { + return Err(FcryError::Format(format!( + "chunk_size {chunk_size} exceeds maximum {MAX_CHUNK_SIZE}" + ))); + } + usize::try_from(chunk_size) + .map_err(|_| FcryError::Format("chunk_size does not fit in usize".into())) +} + +pub fn cipher_chunk_len(plain_chunk_len: usize) -> Result { + plain_chunk_len + .checked_add(TAG_LEN) + .ok_or_else(|| FcryError::Format("cipher chunk length overflow".into())) +} + +pub fn validate_new_argon_params( + memory_mib: u32, + passes: u32, + parallelism: u32, + allow_weak_kdf: bool, +) -> Result { + if !allow_weak_kdf && memory_mib < MIN_ARGON_MEMORY_MIB { + return Err(FcryError::Kdf(format!( + "argon-memory must be at least {MIN_ARGON_MEMORY_MIB} MiB for new encryption (use --allow-weak-kdf only for tests/legacy interop)" + ))); + } + if !allow_weak_kdf && passes < MIN_ARGON_PASSES { + return Err(FcryError::Kdf(format!( + "argon-passes must be at least {MIN_ARGON_PASSES} for new encryption (use --allow-weak-kdf only for tests/legacy interop)" + ))); + } + validate_argon_common(memory_mib, passes, parallelism, "new encryption")?; + mib_to_kib(memory_mib, "argon-memory") +} + +pub fn validate_new_passphrase(pw: &SecretVec, allow_weak_kdf: bool) -> Result<(), FcryError> { + let len = pw.len(); + if len == 0 { + return Err(FcryError::Passphrase("passphrase must not be empty".into())); + } + if !allow_weak_kdf && len < MIN_PASSPHRASE_BYTES { + return Err(FcryError::Passphrase(format!( + "passphrase must be at least {MIN_PASSPHRASE_BYTES} UTF-8 bytes for new encryption (use --allow-weak-kdf only for tests/legacy interop)" + ))); + } + Ok(()) +} + +pub fn validate_header_kdf(kdf: &KdfParams, max_argon_memory_mib: u32) -> Result<(), FcryError> { + match kdf { + KdfParams::Raw => Ok(()), + KdfParams::Argon2id { + m_cost, + t_cost, + p_cost, + .. + } => { + let cap_kib = mib_to_kib(max_argon_memory_mib, "max argon memory")?; + if *m_cost == 0 { + return Err(FcryError::Format("argon2id memory cost must be > 0".into())); + } + if *t_cost == 0 { + return Err(FcryError::Format("argon2id passes must be > 0".into())); + } + if *p_cost == 0 { + return Err(FcryError::Format("argon2id parallelism must be > 0".into())); + } + if *m_cost > cap_kib { + return Err(FcryError::Kdf(format!( + "argon2id memory cost {} KiB exceeds configured decrypt cap {} MiB", + *m_cost, max_argon_memory_mib + ))); + } + if *t_cost > MAX_ARGON_PASSES { + return Err(FcryError::Kdf(format!( + "argon2id passes {} exceeds maximum {}", + *t_cost, MAX_ARGON_PASSES + ))); + } + if *p_cost > MAX_ARGON_PARALLELISM { + return Err(FcryError::Kdf(format!( + "argon2id parallelism {} exceeds maximum {}", + *p_cost, MAX_ARGON_PARALLELISM + ))); + } + Ok(()) + } + } +} + +pub fn validate_argon_common( + memory_mib: u32, + passes: u32, + parallelism: u32, + context: &str, +) -> Result<(), FcryError> { + if memory_mib == 0 { + return Err(FcryError::Kdf(format!( + "argon-memory must be > 0 for {context}" + ))); + } + if passes == 0 { + return Err(FcryError::Kdf(format!( + "argon-passes must be > 0 for {context}" + ))); + } + if passes > MAX_ARGON_PASSES { + return Err(FcryError::Kdf(format!( + "argon-passes {passes} exceeds maximum {MAX_ARGON_PASSES}" + ))); + } + if parallelism == 0 { + return Err(FcryError::Kdf(format!( + "argon-parallelism must be > 0 for {context}" + ))); + } + if parallelism > MAX_ARGON_PARALLELISM { + return Err(FcryError::Kdf(format!( + "argon-parallelism {parallelism} exceeds maximum {MAX_ARGON_PARALLELISM}" + ))); + } + if memory_mib > architecture_argon_cap_mib() { + return Err(FcryError::Kdf(format!( + "argon-memory {memory_mib} exceeds this build's supported cap {}", + architecture_argon_cap_mib() + ))); + } + Ok(()) +} + +pub fn normalize_worker_threads(requested: Option) -> (usize, Option) { + let requested = requested.map(|n| n as usize).unwrap_or_else(|| { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + }); + let capped = requested.clamp(1, MAX_WORKER_THREADS); + let warning = (requested > MAX_WORKER_THREADS).then_some(requested as u32); + (capped, warning) +} + +pub fn pipeline_in_flight_capacity(threads: usize, chunk_len: usize) -> usize { + let chunk_len = chunk_len.max(1); + let thread_cap = threads.saturating_mul(4).max(1); + let byte_cap = (PIPELINE_IN_FLIGHT_BYTES / chunk_len).max(1); + thread_cap.min(byte_cap) +} + +pub fn pipeline_channel_capacity(threads: usize, in_flight: usize) -> usize { + threads.saturating_mul(2).max(1).min(in_flight.max(1)) +} + +pub fn checked_add_u64(a: u64, b: u64, what: &str) -> Result { + a.checked_add(b) + .ok_or_else(|| FcryError::Format(format!("{what} overflow"))) +} + +pub fn checked_mul_u64(a: u64, b: u64, what: &str) -> Result { + a.checked_mul(b) + .ok_or_else(|| FcryError::Format(format!("{what} overflow"))) +} + +pub fn checked_count_add(total: u64, delta: usize, what: &str) -> Result { + checked_add_u64(total, delta as u64, what) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_size_bounds() { + assert!(validate_chunk_size(1).is_ok()); + assert!(validate_chunk_size(MAX_CHUNK_SIZE).is_ok()); + assert!(validate_chunk_size(0).is_err()); + assert!(validate_chunk_size(MAX_CHUNK_SIZE + 1).is_err()); + } + + #[test] + fn argon_cap_override_replaces_default() { + let down = resolve_argon_decrypt_cap(Some(1)).unwrap(); + assert_eq!(down.effective_mib, 1); + assert!(down.overridden); + let default = resolve_argon_decrypt_cap(None).unwrap(); + assert_eq!(default.effective_mib, default.default_mib); + assert!(!default.overridden); + } + + #[test] + fn pipeline_capacity_has_one_chunk_minimum() { + assert_eq!( + pipeline_in_flight_capacity(4, PIPELINE_IN_FLIGHT_BYTES * 2), + 1 + ); + } +} diff --git a/src/reader.rs b/src/reader.rs index 86dad5e..3d9fa09 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -2,6 +2,7 @@ use std::io; use std::io::{BufRead, Read}; +use zeroize::Zeroizing; pub enum ReadInfoChunk { Normal(#[allow(dead_code)] usize), @@ -11,7 +12,7 @@ pub enum ReadInfoChunk { pub struct AheadReader { inner: Box, - buf: Vec, + buf: Zeroizing>, bufsz: usize, capacity: usize, } @@ -20,7 +21,7 @@ impl AheadReader { pub fn from(reader: Box, capacity: usize) -> Self { Self { inner: reader, - buf: vec![0; capacity], + buf: Zeroizing::new(vec![0; capacity]), bufsz: 0, capacity, } @@ -61,7 +62,7 @@ impl AheadReader { } // 2nd read directly into our internal buf - let mut tmp = vec![0u8; self.capacity]; + let mut tmp = Zeroizing::new(vec![0u8; self.capacity]); let n2 = self.read_until_full(&mut tmp)?; self.buf = tmp; self.bufsz = n2; @@ -78,7 +79,7 @@ impl AheadReader { let userbuf_sz = self.bufsz; // 2nd read directly into our internal buf - let mut tmp = vec![0u8; self.capacity]; + let mut tmp = Zeroizing::new(vec![0u8; self.capacity]); let n2 = self.read_until_full(&mut tmp)?; self.buf = tmp; self.bufsz = n2; diff --git a/src/secrets.rs b/src/secrets.rs index 11f8671..b5f6be9 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -95,6 +95,10 @@ impl SecretVec { let inner = self.inner.borrow(); f(&inner[..self.len]) } + + pub fn len(&self) -> usize { + self.len + } } impl PartialEq for SecretVec { @@ -192,13 +196,15 @@ mod imp { mod imp { use super::{MAX_PASSPHRASE_LEN, SecretVec}; use std::fs::OpenOptions; - use std::io::{self, Read, Write}; + use std::io::{self, Write}; use std::os::windows::io::AsRawHandle; + use std::ptr; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::System::Console::{ - ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, ReadConsoleW, SetConsoleMode, }; + use zeroize::Zeroizing; struct ConsoleModeGuard { handle: HANDLE, @@ -214,7 +220,7 @@ mod imp { } pub fn read_passphrase_tty(prompt: &str) -> io::Result { - let mut tty_in = OpenOptions::new().read(true).write(true).open("CONIN$")?; + let tty_in = OpenOptions::new().read(true).write(true).open("CONIN$")?; let mut tty_out = OpenOptions::new().write(true).open("CONOUT$")?; let h_in = tty_in.as_raw_handle() as HANDLE; @@ -236,18 +242,38 @@ mod imp { write!(tty_out, "{prompt}")?; tty_out.flush()?; - let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN); - let mut byte = [0u8; 1]; + let mut wide = Zeroizing::new(Vec::::with_capacity(MAX_PASSPHRASE_LEN)); loop { - match tty_in.read(&mut byte) { - Ok(0) => break, - Ok(_) => match byte[0] { - b'\n' => break, - b'\r' => continue, - b => buf.push(b)?, - }, - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, - Err(e) => return Err(e), + let mut unit = 0u16; + let mut read = 0u32; + let ok = unsafe { + ReadConsoleW( + h_in, + (&mut unit as *mut u16).cast(), + 1, + &mut read, + ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + if read == 0 { + break; + } + const LF: u16 = b'\n' as u16; + const CR: u16 = b'\r' as u16; + match unit { + LF | CR => break, + u => { + if wide.len() >= MAX_PASSPHRASE_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "secret buffer full", + )); + } + wide.push(u); + } } } @@ -255,6 +281,22 @@ mod imp { let _ = writeln!(tty_out); let _ = tty_out.flush(); + let utf8 = Zeroizing::new(String::from_utf16(&wide).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "console passphrase is not valid UTF-16", + ) + })?); + if utf8.len() > MAX_PASSPHRASE_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "secret buffer full", + )); + } + let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN); + for b in utf8.as_bytes() { + buf.push(*b)?; + } Ok(buf) } } diff --git a/src/utils.rs b/src/utils.rs index b5fffc1..1c703b7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,14 +1,16 @@ // SPDX-License-Identifier: GPL-3.0-only -use std::fs::{self, File}; -use std::io::{self, BufRead, BufReader, Write}; -use std::path::PathBuf; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::policy; /// Default plaintext chunk size: 1 MiB. /// /// Stored in the header per file, so callers may override via CLI without /// breaking older files (the decryptor reads the size from the header). -pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024; +pub const DEFAULT_CHUNK_SIZE: u32 = policy::DEFAULT_CHUNK_SIZE; /// Opened input. /// @@ -45,63 +47,225 @@ pub(crate) fn open_input>(input_file: Option) -> io::Result, + pub temp_dir: Option, + pub buffer_verify_stdout: bool, +} + +pub(crate) struct SecureTempFile { + path: PathBuf, + file: Option, + remove_on_drop: bool, +} + +impl SecureTempFile { + fn create(dir: &Path, prefix: &str) -> io::Result { + fs::create_dir_all(dir)?; + for _ in 0..128 { + let mut rand = [0u8; 16]; + getrandom::fill(&mut rand).map_err(io::Error::other)?; + let name = format!("{prefix}.{}.tmp", hex(&rand)); + let path = dir.join(name); + let mut opts = OpenOptions::new(); + opts.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + match opts.open(&path) { + Ok(file) => { + return Ok(Self { + path, + file: Some(file), + remove_on_drop: true, + }); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not create a unique temporary file after 128 attempts", + )) + } + + fn file_mut(&mut self) -> &mut File { + self.file + .as_mut() + .expect("temporary file handle taken before commit") + } + + fn sync_file(&mut self) -> io::Result<()> { + let file = self.file_mut(); + file.flush()?; + file.sync_all() + } + + fn persist(mut self, final_path: &Path) -> io::Result<()> { + self.sync_file()?; + self.file.take(); + #[cfg(windows)] + if final_path.exists() { + fs::remove_file(final_path)?; + } + fs::rename(&self.path, final_path)?; + self.remove_on_drop = false; + best_effort_fsync_parent(final_path); + Ok(()) + } + + fn copy_to_stdout(mut self) -> io::Result<()> { + self.sync_file()?; + let mut file = self + .file + .take() + .expect("temporary file handle taken before stdout commit"); + file.seek(SeekFrom::Start(0))?; + let mut stdout = io::stdout(); + io::copy(&mut file, &mut stdout)?; + stdout.flush()?; + Ok(()) + } +} + +impl Drop for SecureTempFile { + fn drop(&mut self) { + self.file.take(); + if self.remove_on_drop { + let _ = fs::remove_file(&self.path); + } + } +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn best_effort_fsync_parent(path: &Path) { + let Some(parent) = path.parent() else { + return; + }; + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } +} + +fn temp_dir_for_target(final_path: &Path, explicit: Option<&Path>) -> PathBuf { + if let Some(dir) = explicit { + return dir.to_path_buf(); + } + final_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn temp_dir_for_stdout(explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .unwrap_or_else(std::env::temp_dir) +} + +fn file_name_prefix(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("fcry") + .to_owned() +} + +fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result { + let Some(input) = input else { + return Ok(false); + }; + match same_file::is_same_file(input, output) { + Ok(same) => Ok(same), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e), + } +} + /// Output sink that supports atomic file replacement. /// -/// For file outputs: bytes are written to `.tmp`. On `commit()`, the -/// temp file is renamed into place. If dropped without commit (panic, error, -/// process exit), the temp file is deleted so a partial/garbage file does -/// not replace any existing target. +/// For file outputs: bytes are written to a private, randomly named temp file. +/// On `commit()`, the temp file is fsynced and renamed into place. If dropped +/// without commit (panic, error, process exit), the temp file is deleted so a +/// partial/garbage file does not replace any existing target. /// /// For stdout: behaves as a passthrough; `commit()` is a no-op. pub enum OutSink { Stdout(io::Stdout), + BufferVerify { + temp: SecureTempFile, + }, File { - tmp_path: PathBuf, final_path: PathBuf, - file: Option, - committed: bool, + temp: SecureTempFile, }, } impl OutSink { + #[allow(dead_code)] pub fn open>(output_file: Option) -> io::Result { + Self::open_with_options(output_file, &OutSinkOptions::default()) + } + + pub fn open_with_options>( + output_file: Option, + options: &OutSinkOptions, + ) -> io::Result { match output_file { + None if options.buffer_verify_stdout => { + let dir = temp_dir_for_stdout(options.temp_dir.as_deref()); + Ok(Self::BufferVerify { + temp: SecureTempFile::create(&dir, "fcry-buffer")?, + }) + } None => Ok(Self::Stdout(io::stdout())), Some(f) => { let final_path = PathBuf::from(f.as_ref()); - let mut tmp_path = final_path.clone(); - let name = tmp_path - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - let mut tmp_name = name; - tmp_name.push(".tmp"); - tmp_path.set_file_name(tmp_name); - let file = File::create(&tmp_path)?; - Ok(Self::File { - tmp_path, - final_path, - file: Some(file), - committed: false, - }) + if final_path.exists() + && !options.force + && !output_aliases_input(&final_path, options.input_file.as_deref())? + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "output file {} already exists (use --force to replace it)", + final_path.display() + ), + )); + } + let dir = temp_dir_for_target(&final_path, options.temp_dir.as_deref()); + let prefix = file_name_prefix(&final_path); + let temp = SecureTempFile::create(&dir, &prefix)?; + Ok(Self::File { final_path, temp }) } } } pub fn commit(mut self) -> io::Result<()> { - if let Self::File { - tmp_path, - final_path, - file, - committed, - } = &mut self - { - if let Some(mut f) = file.take() { - f.flush()?; - f.sync_all()?; - } - fs::rename(&*tmp_path, &*final_path)?; - *committed = true; + match &mut self { + Self::Stdout(s) => s.flush()?, + Self::BufferVerify { .. } => {} + Self::File { .. } => {} + } + match self { + Self::Stdout(_) => {} + Self::BufferVerify { temp } => temp.copy_to_stdout()?, + Self::File { final_path, temp } => temp.persist(&final_path)?, } Ok(()) } @@ -111,33 +275,16 @@ impl Write for OutSink { fn write(&mut self, buf: &[u8]) -> io::Result { match self { Self::Stdout(s) => s.write(buf), - Self::File { file, .. } => file.as_mut().expect("file taken before commit").write(buf), + Self::BufferVerify { temp } => temp.file_mut().write(buf), + Self::File { temp, .. } => temp.file_mut().write(buf), } } fn flush(&mut self) -> io::Result<()> { match self { Self::Stdout(s) => s.flush(), - Self::File { file, .. } => match file.as_mut() { - Some(f) => f.flush(), - None => Ok(()), - }, - } - } -} - -impl Drop for OutSink { - fn drop(&mut self) { - if let Self::File { - tmp_path, - committed, - file, - .. - } = self - && !*committed - { - file.take(); // close the file before unlink - let _ = fs::remove_file(tmp_path); + Self::BufferVerify { temp } => temp.file_mut().flush(), + Self::File { temp, .. } => temp.file_mut().flush(), } } } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 58e7349..1da119a 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -14,12 +14,21 @@ use assert_cmd::cargo::CommandCargoExt; use tempfile::TempDir; const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef"; -const KEY_STR: &str = "0123456789abcdef0123456789abcdef"; fn fcry() -> Command { Command::cargo_bin("fcry").unwrap() } +fn write_key_file(dir: &std::path::Path) -> std::path::PathBuf { + let key = dir.join("key.bin"); + fs::write(&key, KEY).unwrap(); + key +} + +fn key_file_near(path: &std::path::Path) -> std::path::PathBuf { + write_key_file(path.parent().unwrap()) +} + /// Deterministic pseudo-random plaintext of `n` bytes (xorshift, seedable). /// We avoid `/dev/urandom` so tests are reproducible on failure. fn pseudo_random(seed: u64, n: usize) -> Vec { @@ -37,12 +46,13 @@ fn pseudo_random(seed: u64, n: usize) -> Vec { fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option) { let mut cmd = fcry(); + let key = key_file_near(ct); cmd.arg("-i") .arg(plain) .arg("-o") .arg(ct) - .arg("--raw-key") - .arg(KEY_STR); + .arg("--key-file") + .arg(key); if let Some(cs) = chunk_size { cmd.arg("--chunk-size").arg(cs.to_string()); } @@ -55,14 +65,15 @@ fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Optio } fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) { + let key = key_file_near(ct); let out = fcry() .arg("-d") .arg("-i") .arg(ct) .arg("-o") .arg(rt) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key) .output() .unwrap(); assert!( @@ -133,10 +144,12 @@ fn roundtrip_chunk_size_one_byte() { #[test] fn roundtrip_pipe_stdin_stdout() { let data = pseudo_random(42, 200_000); + let dir = TempDir::new().unwrap(); + let key = write_key_file(dir.path()); let mut enc = fcry() - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(&key) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -152,8 +165,8 @@ fn roundtrip_pipe_stdin_stdout() { let mut dec = fcry() .arg("-d") - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(&key) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -182,19 +195,24 @@ fn rejects_wrong_key() { fs::write(&plain, pseudo_random(1, 1000)).unwrap(); encrypt_file(&plain, &ct, None); - let wrong = "ffffffffffffffffffffffffffffffff"; - assert_ne!(wrong.as_bytes(), KEY); + let wrong = dir.path().join("wrong.key"); + fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap(); let out = fcry() .arg("-d") .arg("-i") .arg(&ct) .arg("-o") .arg(dir.path().join("rt.bin")) - .arg("--raw-key") + .arg("--key-file") .arg(wrong) .output() .unwrap(); assert!(!out.status.success(), "decrypt with wrong key should fail"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("WrongKey"), + "expected distinct WrongKey error, got {}", + String::from_utf8_lossy(&out.stderr) + ); } #[test] @@ -216,8 +234,8 @@ fn rejects_tampered_header() { .arg(&ct) .arg("-o") .arg(dir.path().join("rt.bin")) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key_file_near(&ct)) .output() .unwrap(); assert!( @@ -246,8 +264,8 @@ fn rejects_tampered_ciphertext() { .arg(&ct) .arg("-o") .arg(dir.path().join("rt.bin")) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key_file_near(&ct)) .output() .unwrap(); assert!( @@ -275,8 +293,8 @@ fn rejects_truncated_ciphertext() { .arg(&ct) .arg("-o") .arg(dir.path().join("rt.bin")) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key_file_near(&ct)) .output() .unwrap(); assert!( @@ -296,8 +314,8 @@ fn rejects_bad_magic() { .arg(&bogus) .arg("-o") .arg(dir.path().join("rt.bin")) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(write_key_file(dir.path())) .output() .unwrap(); assert!( @@ -312,25 +330,95 @@ fn rejects_bad_magic() { } #[test] -fn rejects_short_raw_key() { +fn rejects_short_key_file() { let dir = TempDir::new().unwrap(); let plain = dir.path().join("p.bin"); + let key = dir.path().join("short.key"); fs::write(&plain, b"hello").unwrap(); + fs::write(&key, b"tooshort").unwrap(); let out = fcry() .arg("-i") .arg(&plain) .arg("-o") .arg(dir.path().join("c.bin")) - .arg("--raw-key") - .arg("tooshort") + .arg("--key-file") + .arg(&key) .output() .unwrap(); assert!( !out.status.success(), - "encrypt with short raw_key should fail" + "encrypt with short key file should fail" ); } +#[test] +fn rejects_long_key_file_and_trailing_newline() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let key = dir.path().join("long.key"); + fs::write(&plain, b"hello").unwrap(); + fs::write(&key, b"0123456789abcdef0123456789abcdef\n").unwrap(); + let out = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(dir.path().join("c.bin")) + .arg("--key-file") + .arg(&key) + .output() + .unwrap(); + assert!(!out.status.success(), "long key file should fail"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("too long"), + "expected too-long error, got {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn non_utf8_key_file_roundtrips() { + let dir = TempDir::new().unwrap(); + let key = dir.path().join("key.bin"); + let plain = dir.path().join("plain.bin"); + let ct = dir.path().join("ct.bin"); + let rt = dir.path().join("rt.bin"); + let key_bytes: Vec = (0..32u8).map(|b| b ^ 0x80).collect(); + let data = pseudo_random(31, 8192); + fs::write(&key, key_bytes).unwrap(); + fs::write(&plain, &data).unwrap(); + + let enc = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--key-file") + .arg(&key) + .output() + .unwrap(); + assert!( + enc.status.success(), + "non-UTF-8 key encrypt failed: {}", + String::from_utf8_lossy(&enc.stderr) + ); + let dec = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(&rt) + .arg("--key-file") + .arg(&key) + .output() + .unwrap(); + assert!( + dec.status.success(), + "non-UTF-8 key decrypt failed: {}", + String::from_utf8_lossy(&dec.stderr) + ); + assert_eq!(fs::read(&rt).unwrap(), data); +} + #[test] fn roundtrip_passphrase_argon2id() { let dir = TempDir::new().unwrap(); @@ -352,6 +440,7 @@ fn roundtrip_passphrase_argon2id() { .arg("8") .arg("--argon-passes") .arg("1") + .arg("--allow-weak-kdf") .env("FCRY_TEST_PW", "correct horse battery staple") .output() .unwrap(); @@ -394,6 +483,70 @@ fn roundtrip_passphrase_argon2id() { assert!(!bad.status.success(), "wrong passphrase should fail"); } +#[test] +fn weak_passphrase_kdf_rejected_without_override() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + fs::write(&plain, b"hello").unwrap(); + let enc = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(dir.path().join("c.bin")) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .arg("--argon-memory") + .arg("8") + .arg("--argon-passes") + .arg("1") + .env("FCRY_TEST_PW", "short") + .output() + .unwrap(); + assert!(!enc.status.success(), "weak KDF/passphrase should fail"); +} + +#[test] +fn decrypt_argon_memory_cap_rejects_hostile_header() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, b"hello").unwrap(); + let enc = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .arg("--argon-memory") + .arg("8") + .arg("--argon-passes") + .arg("1") + .arg("--allow-weak-kdf") + .env("FCRY_TEST_PW", "correct horse battery staple") + .output() + .unwrap(); + assert!(enc.status.success()); + + let dec = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .arg("--max-argon-memory-mib") + .arg("1") + .env("FCRY_TEST_PW", "correct horse battery staple") + .output() + .unwrap(); + assert!(!dec.status.success(), "low decrypt cap should reject file"); + assert!( + String::from_utf8_lossy(&dec.stderr).contains("decrypt cap"), + "expected cap error, got {}", + String::from_utf8_lossy(&dec.stderr) + ); +} + #[test] fn atomic_output_no_stale_tmp_on_failure() { // A failed decrypt (wrong key) should not leave the output file behind. @@ -404,15 +557,16 @@ fn atomic_output_no_stale_tmp_on_failure() { fs::write(&plain, b"hello world").unwrap(); encrypt_file(&plain, &ct, None); - let wrong = "ffffffffffffffffffffffffffffffff"; + let wrong = dir.path().join("wrong.key"); + fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap(); let out = fcry() .arg("-d") .arg("-i") .arg(&ct) .arg("-o") .arg(&rt) - .arg("--raw-key") - .arg(wrong) + .arg("--key-file") + .arg(&wrong) .output() .unwrap(); assert!(!out.status.success()); @@ -422,6 +576,131 @@ fn atomic_output_no_stale_tmp_on_failure() { assert!(!tmp.exists(), "temp file must be cleaned up"); } +#[test] +fn existing_output_refuses_without_force() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, b"hello").unwrap(); + fs::write(&ct, b"existing").unwrap(); + let out = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!(!out.status.success(), "existing output should refuse"); + assert_eq!(fs::read(&ct).unwrap(), b"existing"); +} + +#[test] +fn force_replaces_only_after_success() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, b"hello").unwrap(); + fs::write(&ct, b"existing").unwrap(); + let out = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--force") + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!( + out.status.success(), + "force encrypt failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_ne!(fs::read(&ct).unwrap(), b"existing"); +} + +#[test] +fn in_place_replacement_roundtrips() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("data.bin"); + let original = pseudo_random(41, 50_000); + fs::write(&path, &original).unwrap(); + + let enc = fcry() + .arg("-i") + .arg(&path) + .arg("-o") + .arg(&path) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!( + enc.status.success(), + "in-place encrypt failed: {}", + String::from_utf8_lossy(&enc.stderr) + ); + assert_ne!(fs::read(&path).unwrap(), original); + + let dec = fcry() + .arg("-d") + .arg("-i") + .arg(&path) + .arg("-o") + .arg(&path) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!( + dec.status.success(), + "in-place decrypt failed: {}", + String::from_utf8_lossy(&dec.stderr) + ); + assert_eq!(fs::read(&path).unwrap(), original); +} + +#[test] +fn old_predictable_temp_name_input_is_not_truncated() { + let dir = TempDir::new().unwrap(); + let input = dir.path().join("out.bin.tmp"); + let output = dir.path().join("out.bin"); + let original = pseudo_random(42, 1024); + fs::write(&input, &original).unwrap(); + let out = fcry() + .arg("-i") + .arg(&input) + .arg("-o") + .arg(&output) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!( + out.status.success(), + "encrypt failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(fs::read(&input).unwrap(), original); + assert!(output.exists()); +} + +#[cfg(unix)] +#[test] +fn output_file_mode_is_0600() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, b"hello").unwrap(); + encrypt_file(&plain, &ct, None); + let mode = fs::metadata(&ct).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} + // --------------------------------------------------------------------------- // Multi-threaded pipeline + length-committed + random-access tests // --------------------------------------------------------------------------- @@ -433,12 +712,13 @@ fn encrypt_file_threads( threads: usize, ) { let mut cmd = fcry(); + let key = key_file_near(ct); cmd.arg("-i") .arg(plain) .arg("-o") .arg(ct) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key) .arg("-j") .arg(threads.to_string()); if let Some(cs) = chunk_size { @@ -453,14 +733,15 @@ fn encrypt_file_threads( } fn decrypt_file_threads(ct: &std::path::Path, rt: &std::path::Path, threads: usize) { + let key = key_file_near(ct); let out = fcry() .arg("-d") .arg("-i") .arg(ct) .arg("-o") .arg(rt) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key) .arg("-j") .arg(threads.to_string()) .output() @@ -517,10 +798,12 @@ fn roundtrip_pipe_multi_threaded() { // length when we don't know the input size), but encrypt/decrypt must still // round-trip cleanly across the pipeline. let data = pseudo_random(14, 200_000); + let dir = TempDir::new().unwrap(); + let key = write_key_file(dir.path()); let mut enc = fcry() - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(&key) .arg("-j") .arg("4") .stdin(Stdio::piped()) @@ -536,16 +819,17 @@ fn roundtrip_pipe_multi_threaded() { String::from_utf8_lossy(&enc_out.stderr) ); - // flags byte at offset 6 must be 0 (no length committed for stdin input). + // flags byte at offset 6 must not set length commitment for stdin input. assert_eq!( - enc_out.stdout[6], 0, + enc_out.stdout[6] & 0x01, + 0, "stdin-encrypted file unexpectedly committed length" ); let mut dec = fcry() .arg("-d") - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(&key) .arg("-j") .arg("4") .stdin(Stdio::piped()) @@ -567,6 +851,97 @@ fn roundtrip_pipe_multi_threaded() { assert_eq!(dec_out.stdout, data); } +#[test] +fn stdin_chunk_size_zero_fails_but_empty_valid_chunk_succeeds() { + let dir = TempDir::new().unwrap(); + let key = write_key_file(dir.path()); + let mut bad = fcry() + .arg("--chunk-size") + .arg("0") + .arg("--key-file") + .arg(&key) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + bad.stdin.as_mut().unwrap().write_all(b"x").unwrap(); + let bad_out = bad.wait_with_output().unwrap(); + assert!(!bad_out.status.success(), "chunk-size 0 should fail"); + + let mut good = fcry() + .arg("--chunk-size") + .arg("1") + .arg("--key-file") + .arg(&key) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + drop(good.stdin.take()); + let good_out = good.wait_with_output().unwrap(); + assert!( + good_out.status.success(), + "empty stdin with valid chunk should succeed: {}", + String::from_utf8_lossy(&good_out.stderr) + ); +} + +#[test] +fn huge_thread_count_is_bounded() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, b"hello").unwrap(); + let out = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .arg("-j") + .arg("1000000") + .output() + .unwrap(); + assert!( + out.status.success(), + "huge -j should be capped, got {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(String::from_utf8_lossy(&out.stderr).contains("capped")); +} + +#[test] +fn forged_huge_chunk_header_fails_before_allocation() { + let dir = TempDir::new().unwrap(); + let forged = dir.path().join("forged.bin"); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"fcry"); + bytes.push(3); // version + bytes.push(1); // alg + bytes.push(0x02); // key commitment flag + bytes.push(0); // reserved + bytes.extend_from_slice(&u32::MAX.to_le_bytes()); + fs::write(&forged, bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&forged) + .arg("--key-file") + .arg(write_key_file(dir.path())) + .output() + .unwrap(); + assert!(!out.status.success(), "huge chunk header should fail"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("chunk_size"), + "expected chunk_size error, got {}", + String::from_utf8_lossy(&out.stderr) + ); +} + #[test] fn file_input_commits_length() { // Encrypting from a regular file must auto-set FLAG_LENGTH_COMMITTED (bit 0 @@ -580,8 +955,39 @@ fn file_input_commits_length() { let bytes = fs::read(&ct).unwrap(); // Magic(4) + version(1) + alg(1) + flags(1) = byte 6 - assert_eq!(bytes[4], 2, "version should be 2"); + assert_eq!(bytes[4], 3, "version should be 3"); assert_eq!(bytes[6] & 0x01, 0x01, "length-committed flag should be set"); + assert_eq!(bytes[6] & 0x02, 0x02, "key-committed flag should be set"); +} + +#[test] +fn v3_downgrade_or_commitment_stripping_fails_authentication() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + let rt = dir.path().join("r.bin"); + fs::write(&plain, pseudo_random(51, 1000)).unwrap(); + encrypt_file(&plain, &ct, None); + + let mut bytes = fs::read(&ct).unwrap(); + bytes[4] = 2; + bytes[6] &= !0x02; + fs::write(&ct, bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(&rt) + .arg("--key-file") + .arg(key_file_near(&ct)) + .output() + .unwrap(); + assert!( + !out.status.success(), + "downgraded/stripped v3 header must fail authentication" + ); } fn encrypt_random_access_fixture( @@ -608,8 +1014,8 @@ fn random_access_decrypt( .arg(ct) .arg("-o") .arg(out) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(key_file_near(ct)) .arg("--offset") .arg(offset.to_string()) .arg("--length") @@ -671,10 +1077,11 @@ fn random_access_rejects_stdin_encrypted() { let data = pseudo_random(18, 2000); let dir = TempDir::new().unwrap(); let ct = dir.path().join("c.bin"); + let key = write_key_file(dir.path()); let mut enc = fcry() - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(&key) .arg("-o") .arg(&ct) .stdin(Stdio::piped()) @@ -693,14 +1100,13 @@ fn random_access_rejects_stdin_encrypted() { } #[test] -fn random_access_zero_length() { +fn random_access_rejects_zero_length() { let dir = TempDir::new().unwrap(); let data = pseudo_random(19, 1000); let ct = encrypt_random_access_fixture(dir.path(), &data, 256); let out = dir.path().join("empty.bin"); let r = random_access_decrypt(&ct, &out, 500, 0); - assert!(r.status.success(), "zero-length slice should succeed"); - assert_eq!(fs::read(&out).unwrap(), Vec::::new()); + assert!(!r.status.success(), "zero-length slice should fail"); } #[test] @@ -723,6 +1129,33 @@ fn random_access_tampered_length_fails() { ); } +#[test] +fn buffer_verify_stdout_emits_nothing_on_truncated_ciphertext() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, pseudo_random(61, 3 * 1024 * 1024)).unwrap(); + encrypt_file(&plain, &ct, Some(64 * 1024)); + let mut bytes = fs::read(&ct).unwrap(); + bytes.truncate(bytes.len() - 32); + fs::write(&ct, bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("--buffer-verify") + .arg("--key-file") + .arg(key_file_near(&ct)) + .output() + .unwrap(); + assert!(!out.status.success(), "truncated decrypt should fail"); + assert!( + out.stdout.is_empty(), + "buffer-verify must suppress partial stdout" + ); +} + #[test] fn rejects_zero_threads() { // -j 0 is almost certainly a user mistake. Clap should reject it before @@ -735,8 +1168,8 @@ fn rejects_zero_threads() { .arg(&plain) .arg("-o") .arg(dir.path().join("c.bin")) - .arg("--raw-key") - .arg(KEY_STR) + .arg("--key-file") + .arg(write_key_file(dir.path())) .arg("-j") .arg("0") .output()