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
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<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>,
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<PathBuf>,
pub output_file: Option<PathBuf>,
pub chunk_size: u32,
pub threads: usize,
pub output: OutputOptions,
}
pub fn encrypt_with_output_options<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<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,
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<S: AsRef<str>>(
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<S: AsRef<str>>(
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<S: AsRef<str>>(
aad,
nonce_prefix,
chunk_sz,
threads,
options.threads,
plaintext_length,
);
}
@@ -182,7 +227,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
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<S: AsRef<str>>(
Ok(())
}
#[allow(dead_code)]
pub fn decrypt<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
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<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 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<S: AsRef<str>>(
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<S: AsRef<str>>(
aad,
header.nonce_prefix,
cipher_chunk,
threads,
options.threads,
header.plaintext_length,
);
}
@@ -306,7 +318,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
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<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>,
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<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 {
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<S: AsRef<str>>(
)
})?;
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<S: AsRef<str>>(
};
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<S: AsRef<str>>(
// 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<u8> = (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<u8> = (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);
}
}
+30 -2
View File
@@ -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<io::Error> for FcryError {
fn from(e: io::Error) -> Self {
FcryError::Io(e)
+20 -8
View File
@@ -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<u8> {
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, 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,
max_argon_memory_mib: u32,
options: HeaderReadOptions,
) -> Result<Self, FcryError> {
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 {
+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
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<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)]
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<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.
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) => {
@@ -196,8 +138,7 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, F
let v = Zeroizing::new(std::env::var(var).map_err(|_| {
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
})?);
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>());
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);
}
}
+20 -15
View File
@@ -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<T: Send + Sync>() {}
const _: fn() = || _assert_send_sync::<Sender<Job>>();
+5 -3
View File
@@ -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;
+12 -9
View File
@@ -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<dyn BufRead + Send>,
buf: Zeroizing<Vec<u8>>,
bufsz: usize,
@@ -18,7 +21,7 @@ pub struct 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 {
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<ReadInfoChunk> {
pub(crate) fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
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<ReadInfoChunk> {
@@ -87,6 +90,6 @@ impl AheadReader {
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
//! 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<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
// ============================================================================
@@ -132,10 +190,13 @@ pub fn read_passphrase_tty(prompt: &str) -> io::Result<SecretVec> {
#[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,
+16 -19
View File
@@ -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<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 {
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<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
}
#[derive(Clone, Debug, Default)]
pub struct OutSinkOptions {
pub struct OutputOptions {
pub force: bool,
pub input_file: Option<PathBuf>,
pub temp_dir: Option<PathBuf>,
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.
///
/// 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<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,
pub(crate) fn open_with_options(
output_file: Option<&Path>,
input_file: Option<&Path>,
options: &OutputOptions,
) -> io::Result<Self> {
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 { .. } => {}
+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,
// 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");