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<PathBuf>/&Path instead of AsRef<str>, 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.
This commit is contained in:
2026-06-12 22:49:23 +02:00
parent f44cfc6190
commit 2f16e735c3
13 changed files with 533 additions and 356 deletions
+3
View File
@@ -0,0 +1,3 @@
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
+147 -172
View File
@@ -1,22 +1,35 @@
// SPDX-License-Identifier: MIT-0 // SPDX-License-Identifier: MIT-0
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace}; use std::{
use std::fs::File; fs::File,
use std::io::{BufReader, Read, Seek, SeekFrom, Write}; io::{BufReader, Read, Seek, SeekFrom, Write},
use std::sync::Arc; path::PathBuf,
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 crate::pipeline;
use crate::policy; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace};
use crate::reader::{AheadReader, ReadInfoChunk};
use crate::secrets::{SecretBytes32, SecretVec};
use crate::utils::*;
use zeroize::Zeroizing; 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 /// 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. /// into a 4-byte big-endian counter and a 1-byte "last block" flag.
pub(crate) const NONCE_LEN: usize = 24; pub(crate) const NONCE_LEN: usize = 24;
@@ -104,40 +117,72 @@ pub(crate) fn bump_counter(counter: u32) -> Result<u32, FcryError> {
.ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into())) .ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into()))
} }
#[allow(dead_code)] #[derive(Clone, Debug)]
pub fn encrypt<S: AsRef<str>>( pub struct EncryptOptions {
input_file: Option<S>, pub input_file: Option<PathBuf>,
output_file: Option<S>, pub output_file: Option<PathBuf>,
key: &SecretBytes32, pub chunk_size: u32,
chunk_size: u32, pub threads: usize,
kdf: KdfParams, pub output: OutputOptions,
threads: usize,
) -> Result<(), FcryError> {
encrypt_with_output_options(
input_file,
output_file,
key,
chunk_size,
kdf,
threads,
&OutSinkOptions::default(),
)
} }
pub fn encrypt_with_output_options<S: AsRef<str>>( impl Default for EncryptOptions {
input_file: Option<S>, fn default() -> Self {
output_file: Option<S>, 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<PathBuf>,
pub output_file: Option<PathBuf>,
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<PathBuf>,
pub offset: u64,
pub length: u64,
pub max_argon_memory_mib: u32,
pub output: OutputOptions,
}
pub fn encrypt(
options: &EncryptOptions,
key: &SecretBytes32, key: &SecretBytes32,
chunk_size: u32,
kdf: KdfParams, kdf: KdfParams,
threads: usize,
output_options: &OutSinkOptions,
) -> Result<(), FcryError> { ) -> Result<(), FcryError> {
let chunk_sz = policy::validate_chunk_size(chunk_size)?; let chunk_sz = policy::validate_chunk_size(options.chunk_size)?;
let input = open_input(input_file)?; let input = open_input(options.input_file.as_deref())?;
let plaintext_length = input.length; let plaintext_length = input.length;
let mut f_plain = AheadReader::from(input.reader, chunk_sz); 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]; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
getrandom::fill(&mut nonce_prefix)?; getrandom::fill(&mut nonce_prefix)?;
@@ -151,7 +196,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
version: VERSION_CURRENT, version: VERSION_CURRENT,
alg: AlgId::XChaCha20Poly1305, alg: AlgId::XChaCha20Poly1305,
flags, flags,
chunk_size, chunk_size: options.chunk_size,
kdf, kdf,
nonce_prefix, nonce_prefix,
plaintext_length, plaintext_length,
@@ -163,7 +208,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
let aead = build_aead(key); let aead = build_aead(key);
if threads > 1 { if options.threads > 1 {
return pipeline::encrypt_parallel( return pipeline::encrypt_parallel(
f_plain, f_plain,
f_encrypted, f_encrypted,
@@ -171,7 +216,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
aad, aad,
nonce_prefix, nonce_prefix,
chunk_sz, chunk_sz,
threads, options.threads,
plaintext_length, plaintext_length,
); );
} }
@@ -182,7 +227,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
loop { loop {
match f_plain.read_ahead(&mut buf)? { match f_plain.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => { ReadInfoChunk::Normal => {
let nonce = make_nonce(&nonce_prefix, counter, false); 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)?; f_encrypted.write_all(&buf)?;
@@ -225,55 +270,18 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
Ok(()) Ok(())
} }
#[allow(dead_code)] pub fn decrypt(
pub fn decrypt<S: AsRef<str>>( options: &DecryptOptions,
input_file: Option<S>,
output_file: Option<S>,
raw_key: Option<&SecretBytes32>, raw_key: Option<&SecretBytes32>,
passphrase: Option<&SecretVec>, passphrase: Option<&SecretVec>,
threads: usize,
) -> Result<(), FcryError> { ) -> Result<(), FcryError> {
decrypt_with_argon_cap( let mut reader = open_input(options.input_file.as_deref())?.reader;
input_file, let header = Header::read_with_options(
output_file, &mut reader,
raw_key, HeaderReadOptions {
passphrase, max_argon_memory_mib: options.max_argon_memory_mib,
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_with_argon_cap(&mut reader, max_argon_memory_mib)?;
let aad = Arc::new(header.encode()); let aad = Arc::new(header.encode());
let key = derive_key(&header.kdf, raw_key, passphrase)?; let key = derive_key(&header.kdf, raw_key, passphrase)?;
@@ -283,11 +291,15 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?; let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?;
let mut f_encrypted = AheadReader::from(reader, cipher_chunk); 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); let aead = build_aead(&key);
if threads > 1 { if options.threads > 1 {
return pipeline::decrypt_parallel( return pipeline::decrypt_parallel(
f_encrypted, f_encrypted,
f_plain, f_plain,
@@ -295,7 +307,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
aad, aad,
header.nonce_prefix, header.nonce_prefix,
cipher_chunk, cipher_chunk,
threads, options.threads,
header.plaintext_length, header.plaintext_length,
); );
} }
@@ -306,7 +318,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
loop { loop {
match f_encrypted.read_ahead(&mut buf)? { match f_encrypted.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => { ReadInfoChunk::Normal => {
let nonce = make_nonce(&header.nonce_prefix, counter, false); 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)?; f_plain.write_all(&buf)?;
@@ -348,65 +360,22 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
/// whose header has `FLAG_LENGTH_COMMITTED` set, so we know exactly where /// 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 /// each ciphertext chunk lives and which chunk is the last (its nonce uses
/// the STREAM last-block flag). /// the STREAM last-block flag).
#[allow(dead_code)] pub fn decrypt_range(
pub fn decrypt_range<S: AsRef<str>>( options: &DecryptRangeOptions,
input_file: &str,
output_file: Option<S>,
raw_key: Option<&SecretBytes32>, raw_key: Option<&SecretBytes32>,
passphrase: Option<&SecretVec>, passphrase: Option<&SecretVec>,
offset: u64,
length: u64,
) -> Result<(), FcryError> { ) -> Result<(), FcryError> {
decrypt_range_with_argon_cap( if options.length == 0 {
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())); 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 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 aad = header.encode();
let header_len = aad.len() as u64; let header_len = aad.len() as u64;
@@ -416,12 +385,14 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
) )
})?; })?;
let end = offset let end = options
.checked_add(length) .offset
.checked_add(options.length)
.ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?; .ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?;
if end > total { if end > total {
return Err(FcryError::Format(format!( 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<S: AsRef<str>>(
}; };
let last_idx = n_chunks - 1; 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; let end_chunk = (end - 1) / chunk_sz;
// Reusable buffer sized to a full chunk + tag. // Reusable buffer sized to a full chunk + tag.
@@ -490,7 +465,7 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
// window in absolute bytes and intersect with the requested range. // 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_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 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; let hi = end.min(chunk_end) - chunk_start;
out.write_all(&buf[lo as usize..hi as usize])?; 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 //! must match the bytes that were authenticated when the file was
//! written. The v1 test below catches the regression where `encode()` //! written. The v1 test below catches the regression where `encode()`
//! used to hard-code the current version on output. //! used to hard-code the current version on output.
use std::fs;
use tempfile::TempDir;
use super::*; use super::*;
use crate::header::{Header, KdfParams, NONCE_PREFIX_LEN}; 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]) { 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, // Build a v1 header by hand: same wire format as v2 with flags=0,
@@ -583,14 +560,13 @@ mod tests {
let plain: Vec<u8> = (0..200u8).collect(); let plain: Vec<u8> = (0..200u8).collect();
write_v1_ciphertext(&ct, &key, &plain); write_v1_ciphertext(&ct, &key, &plain);
decrypt( let options = DecryptOptions {
Some(ct.to_str().unwrap()), input_file: Some(ct.clone()),
Some(rt.to_str().unwrap()), output_file: Some(rt.clone()),
Some(&key), threads: 1,
None, ..DecryptOptions::default()
1, };
) decrypt(&options, Some(&key), None).expect("v1 decrypt should succeed");
.expect("v1 decrypt should succeed");
let got = fs::read(&rt).unwrap(); let got = fs::read(&rt).unwrap();
assert_eq!(got, plain); assert_eq!(got, plain);
} }
@@ -608,14 +584,13 @@ mod tests {
let plain: Vec<u8> = (0..200u8).collect(); let plain: Vec<u8> = (0..200u8).collect();
write_v1_ciphertext(&ct, &key, &plain); write_v1_ciphertext(&ct, &key, &plain);
decrypt( let options = DecryptOptions {
Some(ct.to_str().unwrap()), input_file: Some(ct.clone()),
Some(rt.to_str().unwrap()), output_file: Some(rt.clone()),
Some(&key), threads: 4,
None, ..DecryptOptions::default()
4, };
) decrypt(&options, Some(&key), None).expect("v1 parallel decrypt should succeed");
.expect("v1 parallel decrypt should succeed");
assert_eq!(fs::read(&rt).unwrap(), plain); assert_eq!(fs::read(&rt).unwrap(), plain);
} }
} }
+30 -2
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: MIT-0 // SPDX-License-Identifier: MIT-0
use std::{fmt, io};
use chacha20poly1305::aead; use chacha20poly1305::aead;
use std::io;
#[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub enum FcryError { pub enum FcryError {
Io(io::Error), Io(io::Error),
@@ -15,6 +15,34 @@ pub enum FcryError {
WrongKey, 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<io::Error> for FcryError { impl From<io::Error> for FcryError {
fn from(e: io::Error) -> Self { fn from(e: io::Error) -> Self {
FcryError::Io(e) FcryError::Io(e)
+20 -8
View File
@@ -34,8 +34,7 @@
use std::io::Read; use std::io::Read;
use crate::error::FcryError; use crate::{error::FcryError, policy};
use crate::policy;
const MAGIC: [u8; 4] = *b"fcry"; const MAGIC: [u8; 4] = *b"fcry";
pub const VERSION_CURRENT: u8 = 3; pub const VERSION_CURRENT: u8 = 3;
@@ -152,6 +151,19 @@ pub struct Header {
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>, 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 { impl Header {
fn encode_without_commitment(&self) -> Vec<u8> { fn encode_without_commitment(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(104); let mut out = Vec::with_capacity(104);
@@ -188,14 +200,13 @@ impl Header {
self.encode_without_commitment() self.encode_without_commitment()
} }
#[allow(dead_code)]
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> { pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
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, r: &mut impl Read,
max_argon_memory_mib: u32, options: HeaderReadOptions,
) -> Result<Self, FcryError> { ) -> Result<Self, FcryError> {
let mut magic = [0u8; 4]; let mut magic = [0u8; 4];
r.read_exact(&mut magic)?; r.read_exact(&mut magic)?;
@@ -238,7 +249,7 @@ impl Header {
let mut kdf_id = [0u8; 1]; let mut kdf_id = [0u8; 1];
r.read_exact(&mut kdf_id)?; r.read_exact(&mut kdf_id)?;
let kdf = KdfParams::read_from(kdf_id[0], r)?; 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]; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
r.read_exact(&mut nonce_prefix)?; r.read_exact(&mut nonce_prefix)?;
@@ -274,9 +285,10 @@ impl Header {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use std::io::Cursor; use std::io::Cursor;
use super::*;
#[test] #[test]
fn roundtrip() { fn roundtrip() {
let h = Header { let h = Header {
+64
View File
@@ -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},
};
+42 -105
View File
@@ -1,25 +1,9 @@
// SPDX-License-Identifier: MIT-0 // SPDX-License-Identifier: MIT-0
mod crypto; use std::path::{Path, PathBuf};
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 clap::Parser; use clap::Parser;
use std::fs::File; use fcry::*;
use std::io::Read;
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing; use zeroize::Zeroizing;
/// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use /// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use
@@ -57,15 +41,15 @@ struct Cli {
chunk_size: u32, chunk_size: u32,
/// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB). /// 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, argon_memory: u32,
/// Argon2id passes / iterations (encryption only). /// 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, argon_passes: u32,
/// Argon2id parallelism / lanes (encryption only). /// 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, argon_parallelism: u32,
/// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop. /// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop.
@@ -116,43 +100,6 @@ struct Cli {
length: Option<u64>, length: Option<u64>,
} }
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 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)] #[cfg(unix)]
fn warn_if_key_file_world_readable(path: &Path) { fn warn_if_key_file_world_readable(path: &Path) {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -170,22 +117,17 @@ fn warn_if_key_file_world_readable(path: &Path) {
#[cfg(not(unix))] #[cfg(not(unix))]
fn warn_if_key_file_world_readable(_path: &Path) {} fn warn_if_key_file_world_readable(_path: &Path) {}
fn read_key_file_cli(path: &Path) -> Result<SecretBytes32, FcryError> {
warn_if_key_file_world_readable(path);
read_key_file(path)
}
/// Source of a passphrase: either the terminal or a named env var. /// Source of a passphrase: either the terminal or a named env var.
enum PassphraseSource { enum PassphraseSource {
Tty, Tty,
EnvVar(String), 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> { fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, FcryError> {
match src { match src {
PassphraseSource::EnvVar(var) => { PassphraseSource::EnvVar(var) => {
@@ -196,8 +138,7 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, F
let v = Zeroizing::new(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")) FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
})?); })?);
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>()); normalize_passphrase(SecretVec::from_vec(v.as_bytes().to_vec()))
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
} }
PassphraseSource::Tty => { PassphraseSource::Tty => {
let pw = normalize_passphrase( let pw = normalize_passphrase(
@@ -251,18 +192,18 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
let argon_passes = cli.argon_passes; let argon_passes = cli.argon_passes;
let argon_parallelism = cli.argon_parallelism; let argon_parallelism = cli.argon_parallelism;
let allow_weak_kdf = cli.allow_weak_kdf; 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 { if argon_cap.overridden && argon_cap.effective_mib > argon_cap.default_mib {
eprintln!( eprintln!(
"Warning: --max-argon-memory-mib raises the Argon2 decrypt trust ceiling from {} MiB to {} MiB; this can OOM constrained machines", "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 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 { if let Some(requested) = thread_warning {
eprintln!( eprintln!(
"Warning: requested {requested} worker threads; capped at {}", "Warning: requested {requested} worker threads; capped at {}",
policy::MAX_WORKER_THREADS MAX_WORKER_THREADS
); );
} }
let force = cli.force; let force = cli.force;
@@ -288,16 +229,15 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
)); ));
} }
let output_options = OutSinkOptions { let output_options = OutputOptions {
force, force,
input_file: input.as_ref().map(PathBuf::from),
temp_dir, temp_dir,
buffer_verify_stdout: buffer_verify, buffer_verify_stdout: buffer_verify,
}; };
if decrypt_mode { if decrypt_mode {
let raw_key = match key_file.as_deref() { 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, None => None,
}; };
let pw = match &pw_src { 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(), "--offset/--length require --input-file (random-access needs a seekable file)".into(),
) )
})?; })?;
decrypt_range_with_output_options( let options = DecryptRangeOptions {
path, input_file: PathBuf::from(path),
output, output_file: output.as_deref().map(PathBuf::from),
raw_key.as_ref(), offset: o,
pw.as_ref(), length: l,
o, max_argon_memory_mib: argon_cap.effective_mib,
l, output: output_options.clone(),
argon_cap.effective_mib, };
&output_options, decrypt_range(&options, raw_key.as_ref(), pw.as_ref())?;
)?;
} }
(None, None) => { (None, None) => {
decrypt_with_output_options( let options = DecryptOptions {
input, input_file: input.as_deref().map(PathBuf::from),
output, output_file: output.as_deref().map(PathBuf::from),
raw_key.as_ref(),
pw.as_ref(),
threads, threads,
argon_cap.effective_mib, max_argon_memory_mib: argon_cap.effective_mib,
&output_options, output: output_options.clone(),
)?; };
decrypt(&options, raw_key.as_ref(), pw.as_ref())?;
} }
_ => { _ => {
return Err(FcryError::Format( 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 (key, kdf) = if let Some(src) = &pw_src {
let mut salt = [0u8; ARGON2_SALT_LEN]; let mut salt = [0u8; ARGON2_SALT_LEN];
getrandom::fill(&mut salt)?; getrandom::fill(&mut salt)?;
let m_cost_kib = policy::validate_new_argon_params( let m_cost_kib = validate_new_argon_params(
argon_memory, argon_memory,
argon_passes, argon_passes,
argon_parallelism, argon_parallelism,
@@ -358,22 +296,21 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
p_cost: argon_parallelism, p_cost: argon_parallelism,
}; };
let pw = read_passphrase(src, true)?; 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))?; let key = derive_key(&kdf, None, Some(&pw))?;
(key, kdf) (key, kdf)
} else { } 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) (key, KdfParams::Raw)
}; };
encrypt_with_output_options( let options = EncryptOptions {
input, input_file: input.as_deref().map(PathBuf::from),
output, output_file: output.as_deref().map(PathBuf::from),
&key,
chunk_size, chunk_size,
kdf,
threads, threads,
&output_options, output: output_options,
)?; };
encrypt(&options, &key, kdf)?;
} }
Ok(()) Ok(())
@@ -383,7 +320,7 @@ fn main() {
disable_core_dumps(); disable_core_dumps();
let cli = Cli::parse(); let cli = Cli::parse();
if let Err(e) = run(cli) { if let Err(e) = run(cli) {
eprintln!("Error: {:?}", e); eprintln!("Error: {e}");
std::process::exit(1); std::process::exit(1);
} }
} }
+20 -15
View File
@@ -29,24 +29,30 @@
//! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need //! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need
//! a different memory/throughput tradeoff. //! a different memory/throughput tradeoff.
use std::collections::BTreeMap; use std::{
use std::io::Write; collections::BTreeMap,
use std::sync::Arc; io::Write,
use std::sync::atomic::{AtomicBool, Ordering}; sync::{
use std::thread::{self, JoinHandle}; Arc,
use std::time::Duration; atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace}; use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace};
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded}; 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 zeroize::Zeroizing;
use crate::{
crypto::{bump_counter, make_nonce},
error::FcryError,
header::NONCE_PREFIX_LEN,
policy,
reader::{AheadReader, ReadInfoChunk},
utils::OutSink,
};
struct Job { struct Job {
counter: u32, counter: u32,
last: bool, last: bool,
@@ -197,7 +203,7 @@ fn run_pipeline(
} }
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]); let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
match input.read_ahead(&mut buf)? { match input.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => { ReadInfoChunk::Normal => {
if jobs_tx if jobs_tx
.send(Job { .send(Job {
counter, counter,
@@ -370,6 +376,5 @@ fn ordered_writer(
// Compile-time check that the job type is Send+Sync (channel sends across // Compile-time check that the job type is Send+Sync (channel sends across
// threads). Kept as a footgun for future struct edits. // threads). Kept as a footgun for future struct edits.
#[allow(dead_code)]
fn _assert_send_sync<T: Send + Sync>() {} fn _assert_send_sync<T: Send + Sync>() {}
const _: fn() = || _assert_send_sync::<Sender<Job>>(); const _: fn() = || _assert_send_sync::<Sender<Job>>();
+5 -3
View File
@@ -4,9 +4,11 @@
use std::fs; use std::fs;
use crate::error::FcryError; use crate::{
use crate::header::{KdfParams, TAG_LEN}; error::FcryError,
use crate::secrets::SecretVec; header::{KdfParams, TAG_LEN},
secrets::SecretVec,
};
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024; pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024; pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
+12 -9
View File
@@ -1,16 +1,19 @@
// SPDX-License-Identifier: MIT-0 // SPDX-License-Identifier: MIT-0
use std::io; use std::{
use std::io::{BufRead, Read}; io,
io::{BufRead, Read},
};
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub enum ReadInfoChunk { pub(crate) enum ReadInfoChunk {
Normal(#[allow(dead_code)] usize), Normal,
Last(usize), Last(usize),
Empty, Empty,
} }
pub struct AheadReader { pub(crate) struct AheadReader {
inner: Box<dyn BufRead + Send>, inner: Box<dyn BufRead + Send>,
buf: Zeroizing<Vec<u8>>, buf: Zeroizing<Vec<u8>>,
bufsz: usize, bufsz: usize,
@@ -18,7 +21,7 @@ pub struct AheadReader {
} }
impl AheadReader { impl AheadReader {
pub fn from(reader: Box<dyn BufRead + Send>, capacity: usize) -> Self { pub(crate) fn from(reader: Box<dyn BufRead + Send>, capacity: usize) -> Self {
Self { Self {
inner: reader, inner: reader,
buf: Zeroizing::new(vec![0; capacity]), buf: Zeroizing::new(vec![0; capacity]),
@@ -47,7 +50,7 @@ impl AheadReader {
Ok(total) Ok(total)
} }
pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> { pub(crate) fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
if self.bufsz == 0 { if self.bufsz == 0 {
return self.first_read(userbuf); return self.first_read(userbuf);
} }
@@ -70,7 +73,7 @@ impl AheadReader {
return Ok(ReadInfoChunk::Last(n)); return Ok(ReadInfoChunk::Last(n));
} }
Ok(ReadInfoChunk::Normal(n)) Ok(ReadInfoChunk::Normal)
} }
fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> { fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
@@ -87,6 +90,6 @@ impl AheadReader {
return Ok(ReadInfoChunk::Last(userbuf_sz)); return Ok(ReadInfoChunk::Last(userbuf_sz));
} }
Ok(ReadInfoChunk::Normal(userbuf_sz)) Ok(ReadInfoChunk::Normal)
} }
} }
+84 -13
View File
@@ -14,9 +14,17 @@
//! Windows Console API). Reads into a pre-reserved `SecretVec` so no //! Windows Console API). Reads into a pre-reserved `SecretVec` so no
//! reallocation can leave stale unzeroed copies on the heap. //! 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 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. /// Maximum passphrase length we accept on the tty.
/// Pre-reserved so the underlying Vec never reallocates while reading. /// Pre-reserved so the underlying Vec never reallocates while reading.
@@ -99,6 +107,10 @@ impl SecretVec {
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.len self.len
} }
pub fn is_empty(&self) -> bool {
self.len == 0
}
} }
impl PartialEq for SecretVec { impl PartialEq for SecretVec {
@@ -117,6 +129,52 @@ impl PartialEq for SecretVec {
} }
} }
pub fn read_key_file(path: &Path) -> Result<SecretBytes32, FcryError> {
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<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()))
}
// ============================================================================ // ============================================================================
// tty passphrase reader // tty passphrase reader
// ============================================================================ // ============================================================================
@@ -132,10 +190,13 @@ pub fn read_passphrase_tty(prompt: &str) -> io::Result<SecretVec> {
#[cfg(unix)] #[cfg(unix)]
mod imp { mod imp {
use std::{
fs::OpenOptions,
io::{self, Read, Write},
os::unix::io::AsRawFd,
};
use super::{MAX_PASSPHRASE_LEN, SecretVec}; 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. /// RAII guard that restores the original termios on drop.
struct TermiosGuard { struct TermiosGuard {
@@ -194,18 +255,28 @@ mod imp {
#[cfg(windows)] #[cfg(windows)]
mod imp { mod imp {
use super::{MAX_PASSPHRASE_LEN, SecretVec}; use std::{
use std::fs::OpenOptions; fs::OpenOptions,
use std::io::{self, Write}; io::{self, Write},
use std::os::windows::io::AsRawHandle; os::windows::io::AsRawHandle,
use std::ptr; ptr,
use windows_sys::Win32::Foundation::HANDLE; };
use windows_sys::Win32::System::Console::{
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, ReadConsoleW, use windows_sys::Win32::{
SetConsoleMode, Foundation::HANDLE,
System::Console::{
ENABLE_ECHO_INPUT,
ENABLE_LINE_INPUT,
ENABLE_PROCESSED_INPUT,
GetConsoleMode,
ReadConsoleW,
SetConsoleMode,
},
}; };
use zeroize::Zeroizing; use zeroize::Zeroizing;
use super::{MAX_PASSPHRASE_LEN, SecretVec};
struct ConsoleModeGuard { struct ConsoleModeGuard {
handle: HANDLE, handle: HANDLE,
orig: u32, orig: u32,
+16 -19
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: MIT-0 // SPDX-License-Identifier: MIT-0
use std::fs::{self, File, OpenOptions}; use std::{
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write}; fs::{self, File, OpenOptions},
use std::path::{Path, PathBuf}; io::{self, BufRead, BufReader, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
use crate::policy; use crate::policy;
@@ -22,10 +24,10 @@ pub(crate) struct Input {
pub length: Option<u64>, pub length: Option<u64>,
} }
pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Input> { pub(crate) fn open_input(input_file: Option<&Path>) -> io::Result<Input> {
match input_file { match input_file {
Some(f) => { 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 the open FD (not the path) so we can't be raced between
// stat and open. // stat and open.
let length = file let length = file
@@ -48,9 +50,8 @@ pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct OutSinkOptions { pub struct OutputOptions {
pub force: bool, pub force: bool,
pub input_file: Option<PathBuf>,
pub temp_dir: Option<PathBuf>, pub temp_dir: Option<PathBuf>,
pub buffer_verify_stdout: bool, pub buffer_verify_stdout: bool,
} }
@@ -205,7 +206,7 @@ fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result<bool>
/// partial/garbage file does not replace any existing target. /// partial/garbage file does not replace any existing target.
/// ///
/// For stdout: behaves as a passthrough; `commit()` is a no-op. /// For stdout: behaves as a passthrough; `commit()` is a no-op.
pub enum OutSink { pub(crate) enum OutSink {
Stdout(io::Stdout), Stdout(io::Stdout),
BufferVerify { BufferVerify {
temp: SecureTempFile, temp: SecureTempFile,
@@ -217,14 +218,10 @@ pub enum OutSink {
} }
impl OutSink { impl OutSink {
#[allow(dead_code)] pub(crate) fn open_with_options(
pub fn open<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Self> { output_file: Option<&Path>,
Self::open_with_options(output_file, &OutSinkOptions::default()) input_file: Option<&Path>,
} options: &OutputOptions,
pub fn open_with_options<S: AsRef<str>>(
output_file: Option<S>,
options: &OutSinkOptions,
) -> io::Result<Self> { ) -> io::Result<Self> {
match output_file { match output_file {
None if options.buffer_verify_stdout => { None if options.buffer_verify_stdout => {
@@ -235,10 +232,10 @@ impl OutSink {
} }
None => Ok(Self::Stdout(io::stdout())), None => Ok(Self::Stdout(io::stdout())),
Some(f) => { Some(f) => {
let final_path = PathBuf::from(f.as_ref()); let final_path = f.to_path_buf();
if final_path.exists() if final_path.exists()
&& !options.force && !options.force
&& !output_aliases_input(&final_path, options.input_file.as_deref())? && !output_aliases_input(&final_path, input_file)?
{ {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::AlreadyExists, 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 { match &mut self {
Self::Stdout(s) => s.flush()?, Self::Stdout(s) => s.flush()?,
Self::BufferVerify { .. } => {} Self::BufferVerify { .. } => {}
+82
View File
@@ -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<u8> = (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<u8> = (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]);
}
+8 -10
View File
@@ -6,9 +6,11 @@
// plaintext bytes are preserved, plus a handful of failure cases (tampering, // plaintext bytes are preserved, plus a handful of failure cases (tampering,
// wrong key, truncation, bad magic). // wrong key, truncation, bad magic).
use std::fs; use std::{
use std::io::{ErrorKind, Write}; fs,
use std::process::{Command, Stdio}; io::{ErrorKind, Write},
process::{Command, Stdio},
};
use assert_cmd::cargo::CommandCargoExt; use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir; use tempfile::TempDir;
@@ -209,8 +211,8 @@ fn rejects_wrong_key() {
.unwrap(); .unwrap();
assert!(!out.status.success(), "decrypt with wrong key should fail"); assert!(!out.status.success(), "decrypt with wrong key should fail");
assert!( assert!(
String::from_utf8_lossy(&out.stderr).contains("WrongKey"), String::from_utf8_lossy(&out.stderr).contains("wrong key or passphrase"),
"expected distinct WrongKey error, got {}", "expected distinct wrong-key error, got {}",
String::from_utf8_lossy(&out.stderr) String::from_utf8_lossy(&out.stderr)
); );
} }
@@ -422,11 +424,7 @@ fn non_utf8_key_file_roundtrips() {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn split_fifo_key_file_read_roundtrips() { fn split_fifo_key_file_read_roundtrips() {
use std::ffi::CString; use std::{ffi::CString, fs::OpenOptions, os::unix::ffi::OsStrExt, thread, time::Duration};
use std::fs::OpenOptions;
use std::os::unix::ffi::OsStrExt;
use std::thread;
use std::time::Duration;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let fifo = dir.path().join("key.fifo"); let fifo = dir.path().join("key.fifo");