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:
@@ -0,0 +1,3 @@
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_granularity = "Crate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
+147
-172
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+83
-12
@@ -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,
|
||||
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
@@ -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 { .. } => {}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user