From 2f16e735c35b1ef763c601a237791a898ff8b59f Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 12 Jun 2026 22:49:23 +0200 Subject: [PATCH] feat: split crate into library and thin CLI binary The crypto engine was only reachable through the fcry binary; embedding it in another Rust project meant shelling out to the CLI. Restructure the crate so the binary sits on top of a proper library API. - Add src/lib.rs exposing encrypt/decrypt/decrypt_range/derive_key, the header and policy types, and the secret-handling primitives. - Replace the positional-argument wrapper ladder (encrypt_with_output_options, decrypt_with_argon_cap, ...) with options structs: EncryptOptions, DecryptOptions, DecryptRangeOptions and HeaderReadOptions. OutSinkOptions becomes the public OutputOptions and no longer carries the input path; the input is now an explicit parameter to OutSink::open_with_options so the same-file-aliasing guard's inputs are visible at each call site. - File parameters take Option/&Path instead of AsRef, so non-UTF-8 paths work. - FcryError implements Display and std::error::Error so it composes with anyhow/thiserror-style error handling in downstream crates. - Move read_key_file and normalize_passphrase from main.rs into secrets.rs so library users get the same strict 32-byte key-file parsing and NFC passphrase normalization. The world-readable key-file warning stays in the CLI wrapper (read_key_file_cli). - Drop now-unneeded #[allow(dead_code)] markers; ReadInfoChunk::Normal loses its unused byte-count payload. - Add rustfmt.toml (StdExternalCrate grouping, crate-granularity imports) and reformat imports accordingly. - Add tests/library_api.rs covering a file round-trip and a range decrypt through the public API with a raw key. User-visible change: CLI behavior is unchanged except error output, which is now human-readable Display text ("Error: wrong key or passphrase") instead of the Rust Debug representation. Test plan: cargo clippy (default, --tests, --benches) is clean; cargo +nightly fmt produces no diff; cargo test passes 43 tests including the new library_api integration tests. --- rustfmt.toml | 3 + src/crypto.rs | 319 ++++++++++++++++++++----------------------- src/error.rs | 32 ++++- src/header.rs | 28 ++-- src/lib.rs | 64 +++++++++ src/main.rs | 147 ++++++-------------- src/pipeline.rs | 35 +++-- src/policy.rs | 8 +- src/reader.rs | 21 +-- src/secrets.rs | 97 +++++++++++-- src/utils.rs | 35 +++-- tests/library_api.rs | 82 +++++++++++ tests/roundtrip.rs | 18 ++- 13 files changed, 533 insertions(+), 356 deletions(-) create mode 100644 rustfmt.toml create mode 100644 src/lib.rs create mode 100644 tests/library_api.rs diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e270811 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +group_imports = "StdExternalCrate" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" diff --git a/src/crypto.rs b/src/crypto.rs index 15a7cec..5def79a 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,22 +1,35 @@ // SPDX-License-Identifier: MIT-0 -use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace}; -use std::fs::File; -use std::io::{BufReader, Read, Seek, SeekFrom, Write}; -use std::sync::Arc; - -use crate::error::*; -use crate::header::{ - AlgId, FLAG_KEY_COMMITTED, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN, - VERSION_CURRENT, +use std::{ + fs::File, + io::{BufReader, Read, Seek, SeekFrom, Write}, + path::PathBuf, + sync::Arc, }; -use crate::pipeline; -use crate::policy; -use crate::reader::{AheadReader, ReadInfoChunk}; -use crate::secrets::{SecretBytes32, SecretVec}; -use crate::utils::*; + +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace}; use zeroize::Zeroizing; +use crate::{ + error::*, + header::{ + AlgId, + FLAG_KEY_COMMITTED, + FLAG_LENGTH_COMMITTED, + Header, + HeaderReadOptions, + KdfParams, + NONCE_PREFIX_LEN, + TAG_LEN, + VERSION_CURRENT, + }, + pipeline, + policy, + reader::{AheadReader, ReadInfoChunk}, + secrets::{SecretBytes32, SecretVec}, + utils::*, +}; + /// 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. pub(crate) const NONCE_LEN: usize = 24; @@ -104,40 +117,72 @@ 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, - key: &SecretBytes32, - chunk_size: u32, - kdf: KdfParams, - threads: usize, -) -> Result<(), FcryError> { - encrypt_with_output_options( - input_file, - output_file, - key, - chunk_size, - kdf, - threads, - &OutSinkOptions::default(), - ) +#[derive(Clone, Debug)] +pub struct EncryptOptions { + pub input_file: Option, + pub output_file: Option, + pub chunk_size: u32, + pub threads: usize, + pub output: OutputOptions, } -pub fn encrypt_with_output_options>( - input_file: Option, - output_file: Option, +impl Default for EncryptOptions { + fn default() -> Self { + Self { + input_file: None, + output_file: None, + chunk_size: DEFAULT_CHUNK_SIZE, + threads: policy::normalize_worker_threads(None).0, + output: OutputOptions::default(), + } + } +} + +#[derive(Clone, Debug)] +pub struct DecryptOptions { + pub input_file: Option, + pub output_file: Option, + pub threads: usize, + pub max_argon_memory_mib: u32, + pub output: OutputOptions, +} + +impl Default for DecryptOptions { + fn default() -> Self { + Self { + input_file: None, + output_file: None, + threads: policy::normalize_worker_threads(None).0, + max_argon_memory_mib: policy::default_argon_decrypt_cap_mib(), + output: OutputOptions::default(), + } + } +} + +#[derive(Clone, Debug)] +pub struct DecryptRangeOptions { + pub input_file: PathBuf, + pub output_file: Option, + pub offset: u64, + pub length: u64, + pub max_argon_memory_mib: u32, + pub output: OutputOptions, +} + +pub fn encrypt( + options: &EncryptOptions, 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 chunk_sz = policy::validate_chunk_size(options.chunk_size)?; + let input = open_input(options.input_file.as_deref())?; let plaintext_length = input.length; let mut f_plain = AheadReader::from(input.reader, chunk_sz); - let mut f_encrypted = OutSink::open_with_options(output_file, output_options)?; + let mut f_encrypted = OutSink::open_with_options( + options.output_file.as_deref(), + options.input_file.as_deref(), + &options.output, + )?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; getrandom::fill(&mut nonce_prefix)?; @@ -151,7 +196,7 @@ pub fn encrypt_with_output_options>( version: VERSION_CURRENT, alg: AlgId::XChaCha20Poly1305, flags, - chunk_size, + chunk_size: options.chunk_size, kdf, nonce_prefix, plaintext_length, @@ -163,7 +208,7 @@ pub fn encrypt_with_output_options>( let aead = build_aead(key); - if threads > 1 { + if options.threads > 1 { return pipeline::encrypt_parallel( f_plain, f_encrypted, @@ -171,7 +216,7 @@ pub fn encrypt_with_output_options>( aad, nonce_prefix, chunk_sz, - threads, + options.threads, plaintext_length, ); } @@ -182,7 +227,7 @@ pub fn encrypt_with_output_options>( loop { match f_plain.read_ahead(&mut buf)? { - ReadInfoChunk::Normal(_) => { + ReadInfoChunk::Normal => { let nonce = make_nonce(&nonce_prefix, counter, false); aead.encrypt_in_place(&nonce, &aad, &mut *buf)?; f_encrypted.write_all(&buf)?; @@ -225,55 +270,18 @@ pub fn encrypt_with_output_options>( Ok(()) } -#[allow(dead_code)] -pub fn decrypt>( - input_file: Option, - output_file: Option, +pub fn decrypt( + options: &DecryptOptions, 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_with_argon_cap(&mut reader, max_argon_memory_mib)?; + let mut reader = open_input(options.input_file.as_deref())?.reader; + let header = Header::read_with_options( + &mut reader, + HeaderReadOptions { + max_argon_memory_mib: options.max_argon_memory_mib, + }, + )?; let aad = Arc::new(header.encode()); let key = derive_key(&header.kdf, raw_key, passphrase)?; @@ -283,11 +291,15 @@ pub fn decrypt_with_output_options>( let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?; let mut f_encrypted = AheadReader::from(reader, cipher_chunk); - let mut f_plain = OutSink::open_with_options(output_file, output_options)?; + let mut f_plain = OutSink::open_with_options( + options.output_file.as_deref(), + options.input_file.as_deref(), + &options.output, + )?; let aead = build_aead(&key); - if threads > 1 { + if options.threads > 1 { return pipeline::decrypt_parallel( f_encrypted, f_plain, @@ -295,7 +307,7 @@ pub fn decrypt_with_output_options>( aad, header.nonce_prefix, cipher_chunk, - threads, + options.threads, header.plaintext_length, ); } @@ -306,7 +318,7 @@ pub fn decrypt_with_output_options>( loop { match f_encrypted.read_ahead(&mut buf)? { - ReadInfoChunk::Normal(_) => { + ReadInfoChunk::Normal => { let nonce = make_nonce(&header.nonce_prefix, counter, false); aead.decrypt_in_place(&nonce, &aad, &mut *buf)?; f_plain.write_all(&buf)?; @@ -348,65 +360,22 @@ pub fn decrypt_with_output_options>( /// 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, +pub fn decrypt_range( + options: &DecryptRangeOptions, raw_key: Option<&SecretBytes32>, passphrase: Option<&SecretVec>, - 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 { + if options.length == 0 { return Err(FcryError::Format("--length 0 is not allowed".into())); } - let file = File::open(input_file)?; + let file = File::open(&options.input_file)?; let mut reader = BufReader::new(file); - let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?; + let header = Header::read_with_options( + &mut reader, + HeaderReadOptions { + max_argon_memory_mib: options.max_argon_memory_mib, + }, + )?; let aad = header.encode(); let header_len = aad.len() as u64; @@ -416,12 +385,14 @@ pub fn decrypt_range_with_output_options>( ) })?; - let end = offset - .checked_add(length) + let end = options + .offset + .checked_add(options.length) .ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?; if end > total { return Err(FcryError::Format(format!( - "range [{offset}, {end}) exceeds plaintext length {total}" + "range [{}, {end}) exceeds plaintext length {total}", + options.offset ))); } @@ -451,9 +422,13 @@ pub fn decrypt_range_with_output_options>( }; let last_idx = n_chunks - 1; - let mut out = OutSink::open_with_options(output_file, output_options)?; + let mut out = OutSink::open_with_options( + options.output_file.as_deref(), + Some(&options.input_file), + &options.output, + )?; - let start_chunk = offset / chunk_sz; + let start_chunk = options.offset / chunk_sz; let end_chunk = (end - 1) / chunk_sz; // Reusable buffer sized to a full chunk + tag. @@ -490,7 +465,7 @@ pub fn decrypt_range_with_output_options>( // window in absolute bytes and intersect with the requested range. 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 lo = options.offset.max(chunk_start) - chunk_start; let hi = end.min(chunk_end) - chunk_start; out.write_all(&buf[lo as usize..hi as usize])?; } @@ -506,10 +481,12 @@ mod tests { //! must match the bytes that were authenticated when the file was //! written. The v1 test below catches the regression where `encode()` //! used to hard-code the current version on output. + use std::fs; + + use tempfile::TempDir; + use super::*; use crate::header::{Header, KdfParams, NONCE_PREFIX_LEN}; - use std::fs; - use tempfile::TempDir; fn write_v1_ciphertext(path: &std::path::Path, key: &SecretBytes32, plaintext: &[u8]) { // Build a v1 header by hand: same wire format as v2 with flags=0, @@ -583,14 +560,13 @@ mod tests { let plain: Vec = (0..200u8).collect(); write_v1_ciphertext(&ct, &key, &plain); - decrypt( - Some(ct.to_str().unwrap()), - Some(rt.to_str().unwrap()), - Some(&key), - None, - 1, - ) - .expect("v1 decrypt should succeed"); + let options = DecryptOptions { + input_file: Some(ct.clone()), + output_file: Some(rt.clone()), + threads: 1, + ..DecryptOptions::default() + }; + decrypt(&options, Some(&key), None).expect("v1 decrypt should succeed"); let got = fs::read(&rt).unwrap(); assert_eq!(got, plain); } @@ -608,14 +584,13 @@ mod tests { let plain: Vec = (0..200u8).collect(); write_v1_ciphertext(&ct, &key, &plain); - decrypt( - Some(ct.to_str().unwrap()), - Some(rt.to_str().unwrap()), - Some(&key), - None, - 4, - ) - .expect("v1 parallel decrypt should succeed"); + let options = DecryptOptions { + input_file: Some(ct.clone()), + output_file: Some(rt.clone()), + threads: 4, + ..DecryptOptions::default() + }; + decrypt(&options, Some(&key), None).expect("v1 parallel decrypt should succeed"); assert_eq!(fs::read(&rt).unwrap(), plain); } } diff --git a/src/error.rs b/src/error.rs index ac85337..f0e6589 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT-0 +use std::{fmt, io}; + use chacha20poly1305::aead; -use std::io; -#[allow(dead_code)] #[derive(Debug)] pub enum FcryError { Io(io::Error), @@ -15,6 +15,34 @@ pub enum FcryError { WrongKey, } +impl fmt::Display for FcryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Crypto(_) => write!(f, "cryptographic authentication failed"), + Self::Rng(e) => write!(f, "randomness error: {e}"), + Self::Format(msg) => write!(f, "format error: {msg}"), + Self::Kdf(msg) => write!(f, "KDF error: {msg}"), + Self::Passphrase(msg) => write!(f, "passphrase error: {msg}"), + Self::WrongKey => write!(f, "wrong key or passphrase"), + } + } +} + +impl std::error::Error for FcryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Rng(e) => Some(e), + Self::Crypto(_) + | Self::Format(_) + | Self::Kdf(_) + | Self::Passphrase(_) + | Self::WrongKey => None, + } + } +} + impl From for FcryError { fn from(e: io::Error) -> Self { FcryError::Io(e) diff --git a/src/header.rs b/src/header.rs index 3364a85..cc720f0 100644 --- a/src/header.rs +++ b/src/header.rs @@ -34,8 +34,7 @@ use std::io::Read; -use crate::error::FcryError; -use crate::policy; +use crate::{error::FcryError, policy}; const MAGIC: [u8; 4] = *b"fcry"; pub const VERSION_CURRENT: u8 = 3; @@ -152,6 +151,19 @@ pub struct Header { pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct HeaderReadOptions { + pub max_argon_memory_mib: u32, +} + +impl Default for HeaderReadOptions { + fn default() -> Self { + Self { + max_argon_memory_mib: policy::default_argon_decrypt_cap_mib(), + } + } +} + impl Header { fn encode_without_commitment(&self) -> Vec { let mut out = Vec::with_capacity(104); @@ -188,14 +200,13 @@ impl Header { 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()) + Self::read_with_options(r, HeaderReadOptions::default()) } - pub fn read_with_argon_cap( + pub fn read_with_options( r: &mut impl Read, - max_argon_memory_mib: u32, + options: HeaderReadOptions, ) -> Result { let mut magic = [0u8; 4]; r.read_exact(&mut magic)?; @@ -238,7 +249,7 @@ impl Header { 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)?; + policy::validate_header_kdf(&kdf, options.max_argon_memory_mib)?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; r.read_exact(&mut nonce_prefix)?; @@ -274,9 +285,10 @@ impl Header { #[cfg(test)] mod tests { - use super::*; use std::io::Cursor; + use super::*; + #[test] fn roundtrip() { let h = Header { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d02af64 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT-0 + +mod crypto; +mod error; +mod header; +mod pipeline; +mod policy; +mod reader; +mod secrets; +mod utils; + +pub use crate::{ + crypto::{ + DecryptOptions, + DecryptRangeOptions, + EncryptOptions, + decrypt, + decrypt_range, + derive_key, + encrypt, + }, + error::FcryError, + header::{ + ARGON2_SALT_LEN, + AlgId, + FLAG_KEY_COMMITTED, + FLAG_LENGTH_COMMITTED, + Header, + HeaderReadOptions, + KEY_COMMITMENT_LEN, + KdfParams, + NONCE_PREFIX_LEN, + TAG_LEN, + VERSION_CURRENT, + }, + policy::{ + ArgonDecryptCap, + DEFAULT_ARGON_DECRYPT_CAP_MIB, + DEFAULT_ARGON_MEMORY_MIB, + DEFAULT_ARGON_PARALLELISM, + MAX_ARGON_PARALLELISM, + MAX_ARGON_PASSES, + MAX_CHUNK_SIZE, + MAX_WORKER_THREADS, + MIN_ARGON_MEMORY_MIB, + MIN_ARGON_PASSES, + MIN_PASSPHRASE_BYTES, + architecture_argon_cap_mib, + default_argon_decrypt_cap_mib, + normalize_worker_threads, + resolve_argon_decrypt_cap, + validate_new_argon_params, + validate_new_passphrase, + }, + secrets::{ + MAX_PASSPHRASE_LEN, + SecretBytes32, + SecretVec, + normalize_passphrase, + read_key_file, + read_passphrase_tty, + }, + utils::{DEFAULT_CHUNK_SIZE, OutputOptions}, +}; diff --git a/src/main.rs b/src/main.rs index fdc697f..5f9a2d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,9 @@ // SPDX-License-Identifier: MIT-0 -mod crypto; -mod error; -mod header; -mod pipeline; -mod policy; -mod reader; -mod secrets; -mod utils; - -use crypto::*; -use error::FcryError; -use header::{ARGON2_SALT_LEN, KdfParams}; -use secrets::{SecretBytes32, SecretVec, read_passphrase_tty}; -use utils::{DEFAULT_CHUNK_SIZE, OutSinkOptions}; +use std::path::{Path, PathBuf}; use clap::Parser; -use std::fs::File; -use std::io::Read; -use std::path::{Path, PathBuf}; -use unicode_normalization::UnicodeNormalization; +use fcry::*; use zeroize::Zeroizing; /// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use @@ -57,15 +41,15 @@ struct Cli { chunk_size: u32, /// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB). - #[clap(long, default_value_t = policy::DEFAULT_ARGON_MEMORY_MIB)] + #[clap(long, default_value_t = DEFAULT_ARGON_MEMORY_MIB)] argon_memory: u32, /// Argon2id passes / iterations (encryption only). - #[clap(long, default_value_t = policy::MIN_ARGON_PASSES)] + #[clap(long, default_value_t = MIN_ARGON_PASSES)] argon_passes: u32, /// Argon2id parallelism / lanes (encryption only). - #[clap(long, default_value_t = policy::DEFAULT_ARGON_PARALLELISM)] + #[clap(long, default_value_t = DEFAULT_ARGON_PARALLELISM)] argon_parallelism: u32, /// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop. @@ -116,43 +100,6 @@ struct Cli { length: Option, } -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 mut n = 0usize; - while n < buf.len() { - match file.read(&mut buf[n..]) { - Ok(0) => break, - Ok(read) => n += read, - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(e) => return Err(e.into()), - } - } - if n < 32 { - return Err(FcryError::Format(format!( - "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(&buf[..32])); - Ok(key) -} - #[cfg(unix)] fn warn_if_key_file_world_readable(path: &Path) { use std::os::unix::fs::PermissionsExt; @@ -170,22 +117,17 @@ fn warn_if_key_file_world_readable(path: &Path) { #[cfg(not(unix))] fn warn_if_key_file_world_readable(_path: &Path) {} +fn read_key_file_cli(path: &Path) -> Result { + warn_if_key_file_world_readable(path); + read_key_file(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) => { @@ -196,8 +138,7 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result()); - Ok(SecretVec::from_vec(normalized.as_bytes().to_vec())) + normalize_passphrase(SecretVec::from_vec(v.as_bytes().to_vec())) } PassphraseSource::Tty => { let pw = normalize_passphrase( @@ -251,18 +192,18 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { let argon_passes = cli.argon_passes; let argon_parallelism = cli.argon_parallelism; let allow_weak_kdf = cli.allow_weak_kdf; - let argon_cap = policy::resolve_argon_decrypt_cap(cli.max_argon_memory_mib)?; + let argon_cap = 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); + let (threads, thread_warning) = normalize_worker_threads(cli.threads); if let Some(requested) = thread_warning { eprintln!( "Warning: requested {requested} worker threads; capped at {}", - policy::MAX_WORKER_THREADS + MAX_WORKER_THREADS ); } let force = cli.force; @@ -288,16 +229,15 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { )); } - let output_options = OutSinkOptions { + let output_options = OutputOptions { force, - input_file: input.as_ref().map(PathBuf::from), temp_dir, buffer_verify_stdout: buffer_verify, }; if decrypt_mode { let raw_key = match key_file.as_deref() { - Some(path) => Some(read_key_file(path)?), + Some(path) => Some(read_key_file_cli(path)?), None => None, }; let pw = match &pw_src { @@ -313,27 +253,25 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { "--offset/--length require --input-file (random-access needs a seekable file)".into(), ) })?; - decrypt_range_with_output_options( - path, - output, - raw_key.as_ref(), - pw.as_ref(), - o, - l, - argon_cap.effective_mib, - &output_options, - )?; + let options = DecryptRangeOptions { + input_file: PathBuf::from(path), + output_file: output.as_deref().map(PathBuf::from), + offset: o, + length: l, + max_argon_memory_mib: argon_cap.effective_mib, + output: output_options.clone(), + }; + decrypt_range(&options, raw_key.as_ref(), pw.as_ref())?; } (None, None) => { - decrypt_with_output_options( - input, - output, - raw_key.as_ref(), - pw.as_ref(), + let options = DecryptOptions { + input_file: input.as_deref().map(PathBuf::from), + output_file: output.as_deref().map(PathBuf::from), threads, - argon_cap.effective_mib, - &output_options, - )?; + max_argon_memory_mib: argon_cap.effective_mib, + output: output_options.clone(), + }; + decrypt(&options, raw_key.as_ref(), pw.as_ref())?; } _ => { return Err(FcryError::Format( @@ -345,7 +283,7 @@ 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 = policy::validate_new_argon_params( + let m_cost_kib = validate_new_argon_params( argon_memory, argon_passes, argon_parallelism, @@ -358,22 +296,21 @@ 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)?; + validate_new_passphrase(&pw, allow_weak_kdf)?; let key = derive_key(&kdf, None, Some(&pw))?; (key, kdf) } else { - let key = read_key_file(key_file.as_deref().unwrap())?; + let key = read_key_file_cli(key_file.as_deref().unwrap())?; (key, KdfParams::Raw) }; - encrypt_with_output_options( - input, - output, - &key, + let options = EncryptOptions { + input_file: input.as_deref().map(PathBuf::from), + output_file: output.as_deref().map(PathBuf::from), chunk_size, - kdf, threads, - &output_options, - )?; + output: output_options, + }; + encrypt(&options, &key, kdf)?; } Ok(()) @@ -383,7 +320,7 @@ fn main() { disable_core_dumps(); let cli = Cli::parse(); if let Err(e) = run(cli) { - eprintln!("Error: {:?}", e); + eprintln!("Error: {e}"); std::process::exit(1); } } diff --git a/src/pipeline.rs b/src/pipeline.rs index b911dd7..a06e34c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -29,24 +29,30 @@ //! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need //! a different memory/throughput tradeoff. -use std::collections::BTreeMap; -use std::io::Write; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::{ + collections::BTreeMap, + io::Write, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace}; 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; +use crate::{ + crypto::{bump_counter, make_nonce}, + error::FcryError, + header::NONCE_PREFIX_LEN, + policy, + reader::{AheadReader, ReadInfoChunk}, + utils::OutSink, +}; + struct Job { counter: u32, last: bool, @@ -197,7 +203,7 @@ fn run_pipeline( } let mut buf = Zeroizing::new(vec![0u8; chunk_sz]); match input.read_ahead(&mut buf)? { - ReadInfoChunk::Normal(_) => { + ReadInfoChunk::Normal => { if jobs_tx .send(Job { counter, @@ -370,6 +376,5 @@ fn ordered_writer( // Compile-time check that the job type is Send+Sync (channel sends across // threads). Kept as a footgun for future struct edits. -#[allow(dead_code)] fn _assert_send_sync() {} const _: fn() = || _assert_send_sync::>(); diff --git a/src/policy.rs b/src/policy.rs index 1f22f5b..bf6861f 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -4,9 +4,11 @@ use std::fs; -use crate::error::FcryError; -use crate::header::{KdfParams, TAG_LEN}; -use crate::secrets::SecretVec; +use crate::{ + error::FcryError, + header::{KdfParams, TAG_LEN}, + secrets::SecretVec, +}; pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024; pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024; diff --git a/src/reader.rs b/src/reader.rs index 6a46118..75ec93d 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -1,16 +1,19 @@ // SPDX-License-Identifier: MIT-0 -use std::io; -use std::io::{BufRead, Read}; +use std::{ + io, + io::{BufRead, Read}, +}; + use zeroize::Zeroizing; -pub enum ReadInfoChunk { - Normal(#[allow(dead_code)] usize), +pub(crate) enum ReadInfoChunk { + Normal, Last(usize), Empty, } -pub struct AheadReader { +pub(crate) struct AheadReader { inner: Box, buf: Zeroizing>, bufsz: usize, @@ -18,7 +21,7 @@ pub struct AheadReader { } impl AheadReader { - pub fn from(reader: Box, capacity: usize) -> Self { + pub(crate) fn from(reader: Box, capacity: usize) -> Self { Self { inner: reader, buf: Zeroizing::new(vec![0; capacity]), @@ -47,7 +50,7 @@ impl AheadReader { Ok(total) } - pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result { + pub(crate) fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result { if self.bufsz == 0 { return self.first_read(userbuf); } @@ -70,7 +73,7 @@ impl AheadReader { return Ok(ReadInfoChunk::Last(n)); } - Ok(ReadInfoChunk::Normal(n)) + Ok(ReadInfoChunk::Normal) } fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result { @@ -87,6 +90,6 @@ impl AheadReader { return Ok(ReadInfoChunk::Last(userbuf_sz)); } - Ok(ReadInfoChunk::Normal(userbuf_sz)) + Ok(ReadInfoChunk::Normal) } } diff --git a/src/secrets.rs b/src/secrets.rs index ee5a1f6..16da66a 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -14,9 +14,17 @@ //! Windows Console API). Reads into a pre-reserved `SecretVec` so no //! reallocation can leave stale unzeroed copies on the heap. -use std::io; +use std::{ + fs::File, + io::{self, Read}, + path::Path, +}; use protected_secrets::{SecretBox as ProtectedSecretBox, SecretVec as ProtectedSecretVec}; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; + +use crate::error::FcryError; /// Maximum passphrase length we accept on the tty. /// Pre-reserved so the underlying Vec never reallocates while reading. @@ -99,6 +107,10 @@ impl SecretVec { pub fn len(&self) -> usize { self.len } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } } impl PartialEq for SecretVec { @@ -117,6 +129,52 @@ impl PartialEq for SecretVec { } } +pub fn read_key_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut buf = Zeroizing::new([0u8; 33]); + let mut n = 0usize; + while n < buf.len() { + match file.read(&mut buf[n..]) { + Ok(0) => break, + Ok(read) => n += read, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + } + if n < 32 { + return Err(FcryError::Format(format!( + "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(&buf[..32])); + Ok(key) +} + +pub 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())) +} + // ============================================================================ // tty passphrase reader // ============================================================================ @@ -132,10 +190,13 @@ pub fn read_passphrase_tty(prompt: &str) -> io::Result { #[cfg(unix)] mod imp { + use std::{ + fs::OpenOptions, + io::{self, Read, Write}, + os::unix::io::AsRawFd, + }; + use super::{MAX_PASSPHRASE_LEN, SecretVec}; - use std::fs::OpenOptions; - use std::io::{self, Read, Write}; - use std::os::unix::io::AsRawFd; /// RAII guard that restores the original termios on drop. struct TermiosGuard { @@ -194,18 +255,28 @@ mod imp { #[cfg(windows)] mod imp { - use super::{MAX_PASSPHRASE_LEN, SecretVec}; - use std::fs::OpenOptions; - 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, ReadConsoleW, - SetConsoleMode, + use std::{ + fs::OpenOptions, + io::{self, Write}, + os::windows::io::AsRawHandle, + ptr, + }; + + use windows_sys::Win32::{ + Foundation::HANDLE, + System::Console::{ + ENABLE_ECHO_INPUT, + ENABLE_LINE_INPUT, + ENABLE_PROCESSED_INPUT, + GetConsoleMode, + ReadConsoleW, + SetConsoleMode, + }, }; use zeroize::Zeroizing; + use super::{MAX_PASSPHRASE_LEN, SecretVec}; + struct ConsoleModeGuard { handle: HANDLE, orig: u32, diff --git a/src/utils.rs b/src/utils.rs index 66c66f9..89686c1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: MIT-0 -use std::fs::{self, File, OpenOptions}; -use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, BufRead, BufReader, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; use crate::policy; @@ -22,10 +24,10 @@ pub(crate) struct Input { pub length: Option, } -pub(crate) fn open_input>(input_file: Option) -> io::Result { +pub(crate) fn open_input(input_file: Option<&Path>) -> io::Result { match input_file { Some(f) => { - let file = File::open(f.as_ref())?; + let file = File::open(f)?; // Stat the open FD (not the path) so we can't be raced between // stat and open. let length = file @@ -48,9 +50,8 @@ pub(crate) fn open_input>(input_file: Option) -> io::Result, pub temp_dir: Option, pub buffer_verify_stdout: bool, } @@ -205,7 +206,7 @@ fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result /// partial/garbage file does not replace any existing target. /// /// For stdout: behaves as a passthrough; `commit()` is a no-op. -pub enum OutSink { +pub(crate) enum OutSink { Stdout(io::Stdout), BufferVerify { temp: SecureTempFile, @@ -217,14 +218,10 @@ pub enum OutSink { } 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, + pub(crate) fn open_with_options( + output_file: Option<&Path>, + input_file: Option<&Path>, + options: &OutputOptions, ) -> io::Result { match output_file { None if options.buffer_verify_stdout => { @@ -235,10 +232,10 @@ impl OutSink { } None => Ok(Self::Stdout(io::stdout())), Some(f) => { - let final_path = PathBuf::from(f.as_ref()); + let final_path = f.to_path_buf(); if final_path.exists() && !options.force - && !output_aliases_input(&final_path, options.input_file.as_deref())? + && !output_aliases_input(&final_path, input_file)? { return Err(io::Error::new( io::ErrorKind::AlreadyExists, @@ -256,7 +253,7 @@ impl OutSink { } } - pub fn commit(mut self) -> io::Result<()> { + pub(crate) fn commit(mut self) -> io::Result<()> { match &mut self { Self::Stdout(s) => s.flush()?, Self::BufferVerify { .. } => {} diff --git a/tests/library_api.rs b/tests/library_api.rs new file mode 100644 index 0000000..f03bc39 --- /dev/null +++ b/tests/library_api.rs @@ -0,0 +1,82 @@ +use std::fs; + +use fcry::{ + DecryptOptions, + DecryptRangeOptions, + EncryptOptions, + KdfParams, + OutputOptions, + SecretBytes32, + decrypt, + decrypt_range, + encrypt, +}; +use tempfile::TempDir; + +fn test_key() -> SecretBytes32 { + let mut key = SecretBytes32::zeroed(); + key.with_mut_array(|key| key.copy_from_slice(b"0123456789abcdef0123456789abcdef")); + key +} + +#[test] +fn library_file_roundtrip_raw_key() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("plain.bin"); + let ct = dir.path().join("cipher.fcry"); + let out = dir.path().join("out.bin"); + let data: Vec = (0..=255).cycle().take(100_000).collect(); + fs::write(&plain, &data).unwrap(); + + let key = test_key(); + let encrypt_options = EncryptOptions { + input_file: Some(plain), + output_file: Some(ct.clone()), + chunk_size: 4096, + threads: 1, + output: OutputOptions::default(), + }; + encrypt(&encrypt_options, &key, KdfParams::Raw).unwrap(); + + let decrypt_options = DecryptOptions { + input_file: Some(ct), + output_file: Some(out.clone()), + threads: 1, + ..DecryptOptions::default() + }; + decrypt(&decrypt_options, Some(&key), None).unwrap(); + + assert_eq!(fs::read(out).unwrap(), data); +} + +#[test] +fn library_range_decrypt_raw_key() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("plain.bin"); + let ct = dir.path().join("cipher.fcry"); + let out = dir.path().join("slice.bin"); + let data: Vec = (0..=255).cycle().take(50_000).collect(); + fs::write(&plain, &data).unwrap(); + + let key = test_key(); + let encrypt_options = EncryptOptions { + input_file: Some(plain), + output_file: Some(ct.clone()), + chunk_size: 1024, + threads: 2, + output: OutputOptions::default(), + }; + encrypt(&encrypt_options, &key, KdfParams::Raw).unwrap(); + + let range_options = DecryptRangeOptions { + input_file: ct, + output_file: Some(out.clone()), + offset: 1234, + length: 20_000, + max_argon_memory_mib: DecryptOptions::default().max_argon_memory_mib, + output: OutputOptions::default(), + }; + decrypt_range(&range_options, Some(&key), None).unwrap(); + + assert_eq!(fs::read(out).unwrap(), data[1234..21_234]); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 5fa91bd..d87c3f3 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -6,9 +6,11 @@ // plaintext bytes are preserved, plus a handful of failure cases (tampering, // wrong key, truncation, bad magic). -use std::fs; -use std::io::{ErrorKind, Write}; -use std::process::{Command, Stdio}; +use std::{ + fs, + io::{ErrorKind, Write}, + process::{Command, Stdio}, +}; use assert_cmd::cargo::CommandCargoExt; use tempfile::TempDir; @@ -209,8 +211,8 @@ fn rejects_wrong_key() { .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).contains("wrong key or passphrase"), + "expected distinct wrong-key error, got {}", String::from_utf8_lossy(&out.stderr) ); } @@ -422,11 +424,7 @@ fn non_utf8_key_file_roundtrips() { #[cfg(unix)] #[test] fn split_fifo_key_file_read_roundtrips() { - use std::ffi::CString; - use std::fs::OpenOptions; - use std::os::unix::ffi::OsStrExt; - use std::thread; - use std::time::Duration; + use std::{ffi::CString, fs::OpenOptions, os::unix::ffi::OsStrExt, thread, time::Duration}; let dir = TempDir::new().unwrap(); let fifo = dir.path().join("key.fifo");