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
+185 -36
View File
@@ -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<XChaCha20Poly1305> {
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<u32, FcryError> {
@@ -75,6 +104,7 @@ pub(crate) fn bump_counter(counter: u32) -> Result<u32, FcryError> {
.ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into()))
}
#[allow(dead_code)]
pub fn encrypt<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
@@ -83,11 +113,31 @@ pub fn encrypt<S: AsRef<str>>(
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<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<S: AsRef<str>>(
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<S: AsRef<str>>(
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<S: AsRef<str>>(
);
}
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<S: AsRef<str>>(
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<S: AsRef<str>>(
// 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<S: AsRef<str>>(
Ok(())
}
#[allow(dead_code)]
pub fn decrypt<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<S: AsRef<str>>(
);
}
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<S: AsRef<str>>(
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<S: AsRef<str>>(
/// 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<S: AsRef<str>>(
input_file: &str,
output_file: Option<S>,
@@ -261,9 +357,56 @@ pub fn decrypt_range<S: AsRef<str>>(
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<S: AsRef<str>>(
input_file: &str,
output_file: Option<S>,
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<S: AsRef<str>>(
input_file: &str,
output_file: Option<S>,
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<S: AsRef<str>>(
}
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<S: AsRef<str>>(
(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<S: AsRef<str>>(
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
+1
View File
@@ -12,6 +12,7 @@ pub enum FcryError {
Format(String),
Kdf(String),
Passphrase(String),
WrongKey,
}
impl From<io::Error> for FcryError {
+91 -10
View File
@@ -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<u64>,
/// v3 key commitment. `Some` iff `flags & FLAG_KEY_COMMITTED`.
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>,
}
impl Header {
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(72);
fn encode_without_commitment(&self) -> Vec<u8> {
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<u8> {
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<u8> {
self.encode_without_commitment()
}
#[allow(dead_code)]
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
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<Self, FcryError> {
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);
+170 -38
View File
@@ -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<String>,
/// 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<Zeroizing<String>>,
/// Read the raw 32-byte crypto key from a file.
#[clap(short = 'k', long, conflicts_with_all = ["passphrase", "passphrase_env"])]
key_file: Option<PathBuf>,
/// 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<u32>,
/// 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<u32>,
/// Replace an existing different output file after encryption/decryption succeeds.
#[clap(long)]
force: bool,
/// Directory for private temporary files.
#[clap(long)]
temp_dir: Option<PathBuf>,
/// 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<u64>,
}
fn parse_raw_key(s: &str) -> Result<SecretBytes32, FcryError> {
let raw = s.as_bytes();
if raw.len() != 32 {
fn read_key_file(path: &Path) -> Result<SecretBytes32, FcryError> {
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<SecretVec, FcryError> {
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::<Zeroizing<String>, FcryError>(Zeroizing::new(s.nfc().collect::<String>()))
})?;
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
}
fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, FcryError> {
match src {
PassphraseSource::EnvVar(var) => {
@@ -117,17 +185,22 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, F
// protected storage. The source Vec is zeroed after the copy.
// Note: a copy still exists in the process `environ` table; that is
// a known and accepted leak for the env-var path.
let v = std::env::var(var).map_err(|_| {
let v = Zeroizing::new(std::env::var(var).map_err(|_| {
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
})?;
Ok(SecretVec::from_vec(v.into_bytes()))
})?);
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>());
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<Zeroizing<String>> = cli.raw_key.take();
let key_file: Option<PathBuf> = cli.key_file.take();
let pw_src: Option<PassphraseSource> = 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(())
+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;
+299
View File
@@ -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<u32> {
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::<u64>().ok()?;
return u32::try_from(kib / 1024).ok();
}
None
}
#[cfg(not(target_os = "linux"))]
fn available_memory_mib() -> Option<u32> {
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<u32>) -> Result<ArgonDecryptCap, FcryError> {
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<u32, FcryError> {
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<usize, FcryError> {
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<usize, FcryError> {
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<u32, FcryError> {
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<u32>) -> (usize, Option<u32>) {
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<u64, FcryError> {
a.checked_add(b)
.ok_or_else(|| FcryError::Format(format!("{what} overflow")))
}
pub fn checked_mul_u64(a: u64, b: u64, what: &str) -> Result<u64, FcryError> {
a.checked_mul(b)
.ok_or_else(|| FcryError::Format(format!("{what} overflow")))
}
pub fn checked_count_add(total: u64, delta: usize, what: &str) -> Result<u64, FcryError> {
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
);
}
}
+5 -4
View File
@@ -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<dyn BufRead + Send>,
buf: Vec<u8>,
buf: Zeroizing<Vec<u8>>,
bufsz: usize,
capacity: usize,
}
@@ -20,7 +21,7 @@ impl AheadReader {
pub fn from(reader: Box<dyn BufRead + Send>, 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;
+56 -14
View File
@@ -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<SecretVec> {
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::<u16>::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)
}
}
+207 -60
View File
@@ -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<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
}
}
#[derive(Clone, Debug, Default)]
pub struct OutSinkOptions {
pub force: bool,
pub input_file: Option<PathBuf>,
pub temp_dir: Option<PathBuf>,
pub buffer_verify_stdout: bool,
}
pub(crate) struct SecureTempFile {
path: PathBuf,
file: Option<File>,
remove_on_drop: bool,
}
impl SecureTempFile {
fn create(dir: &Path, prefix: &str) -> io::Result<Self> {
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<bool> {
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 `<path>.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<File>,
committed: bool,
temp: SecureTempFile,
},
}
impl OutSink {
#[allow(dead_code)]
pub fn open<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Self> {
Self::open_with_options(output_file, &OutSinkOptions::default())
}
pub fn open_with_options<S: AsRef<str>>(
output_file: Option<S>,
options: &OutSinkOptions,
) -> io::Result<Self> {
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<usize> {
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(),
}
}
}