feat: harden fcry format and IO policy
Introduce a central policy module for format and resource validation, then route header parsing, KDF acceptance, range arithmetic, and pipeline sizing through that policy. New encryptions now write v3 headers that include an authenticated key commitment, which lets decrypt reject wrong keys or passphrases before chunk processing while preserving valid v1/v2 decrypt compatibility inside the configured caps. Replace process-list-visible raw key input with --key-file, add passphrase NFC normalization, enforce stronger new-encryption passphrase/KDF floors unless --allow-weak-kdf is supplied, and add a configurable decrypt Argon2 memory ceiling. Chunk buffers in the serial, parallel, and lookahead paths now use zeroizing storage. Rework output handling around randomized create-new temporary files with Unix 0600 mode, file fsync before persist, best-effort parent directory fsync, default no-overwrite behavior, safe in-place replacement, --force, --temp-dir, and --buffer-verify for decrypt-to-stdout. Known caveat: --key-file currently reads with a single read call. That is fine for regular files but can reject short reads from pipes or process substitution. A follow-up fix will make key-file reads loop before EOF. Test Plan: - cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test - git diff --check - cargo run -- --help Refs: fcry security hardening plan
This commit is contained in:
+207
-60
@@ -1,14 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// Default plaintext chunk size: 1 MiB.
|
||||
///
|
||||
/// Stored in the header per file, so callers may override via CLI without
|
||||
/// breaking older files (the decryptor reads the size from the header).
|
||||
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
|
||||
pub const DEFAULT_CHUNK_SIZE: u32 = policy::DEFAULT_CHUNK_SIZE;
|
||||
|
||||
/// Opened input.
|
||||
///
|
||||
@@ -45,63 +47,225 @@ pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OutSinkOptions {
|
||||
pub force: bool,
|
||||
pub input_file: Option<PathBuf>,
|
||||
pub temp_dir: Option<PathBuf>,
|
||||
pub buffer_verify_stdout: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct SecureTempFile {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
remove_on_drop: bool,
|
||||
}
|
||||
|
||||
impl SecureTempFile {
|
||||
fn create(dir: &Path, prefix: &str) -> io::Result<Self> {
|
||||
fs::create_dir_all(dir)?;
|
||||
for _ in 0..128 {
|
||||
let mut rand = [0u8; 16];
|
||||
getrandom::fill(&mut rand).map_err(io::Error::other)?;
|
||||
let name = format!("{prefix}.{}.tmp", hex(&rand));
|
||||
let path = dir.join(name);
|
||||
let mut opts = OpenOptions::new();
|
||||
opts.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
match opts.open(&path) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
path,
|
||||
file: Some(file),
|
||||
remove_on_drop: true,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"could not create a unique temporary file after 128 attempts",
|
||||
))
|
||||
}
|
||||
|
||||
fn file_mut(&mut self) -> &mut File {
|
||||
self.file
|
||||
.as_mut()
|
||||
.expect("temporary file handle taken before commit")
|
||||
}
|
||||
|
||||
fn sync_file(&mut self) -> io::Result<()> {
|
||||
let file = self.file_mut();
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
fn persist(mut self, final_path: &Path) -> io::Result<()> {
|
||||
self.sync_file()?;
|
||||
self.file.take();
|
||||
#[cfg(windows)]
|
||||
if final_path.exists() {
|
||||
fs::remove_file(final_path)?;
|
||||
}
|
||||
fs::rename(&self.path, final_path)?;
|
||||
self.remove_on_drop = false;
|
||||
best_effort_fsync_parent(final_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_stdout(mut self) -> io::Result<()> {
|
||||
self.sync_file()?;
|
||||
let mut file = self
|
||||
.file
|
||||
.take()
|
||||
.expect("temporary file handle taken before stdout commit");
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut stdout = io::stdout();
|
||||
io::copy(&mut file, &mut stdout)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecureTempFile {
|
||||
fn drop(&mut self) {
|
||||
self.file.take();
|
||||
if self.remove_on_drop {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn best_effort_fsync_parent(path: &Path) {
|
||||
let Some(parent) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(dir) = File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir_for_target(final_path: &Path, explicit: Option<&Path>) -> PathBuf {
|
||||
if let Some(dir) = explicit {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
final_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn temp_dir_for_stdout(explicit: Option<&Path>) -> PathBuf {
|
||||
explicit
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
|
||||
fn file_name_prefix(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("fcry")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result<bool> {
|
||||
let Some(input) = input else {
|
||||
return Ok(false);
|
||||
};
|
||||
match same_file::is_same_file(input, output) {
|
||||
Ok(same) => Ok(same),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sink that supports atomic file replacement.
|
||||
///
|
||||
/// For file outputs: bytes are written to `<path>.tmp`. On `commit()`, the
|
||||
/// temp file is renamed into place. If dropped without commit (panic, error,
|
||||
/// process exit), the temp file is deleted so a partial/garbage file does
|
||||
/// not replace any existing target.
|
||||
/// For file outputs: bytes are written to a private, randomly named temp file.
|
||||
/// On `commit()`, the temp file is fsynced and renamed into place. If dropped
|
||||
/// without commit (panic, error, process exit), the temp file is deleted so a
|
||||
/// partial/garbage file does not replace any existing target.
|
||||
///
|
||||
/// For stdout: behaves as a passthrough; `commit()` is a no-op.
|
||||
pub enum OutSink {
|
||||
Stdout(io::Stdout),
|
||||
BufferVerify {
|
||||
temp: SecureTempFile,
|
||||
},
|
||||
File {
|
||||
tmp_path: PathBuf,
|
||||
final_path: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
temp: SecureTempFile,
|
||||
},
|
||||
}
|
||||
|
||||
impl OutSink {
|
||||
#[allow(dead_code)]
|
||||
pub fn open<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Self> {
|
||||
Self::open_with_options(output_file, &OutSinkOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_with_options<S: AsRef<str>>(
|
||||
output_file: Option<S>,
|
||||
options: &OutSinkOptions,
|
||||
) -> io::Result<Self> {
|
||||
match output_file {
|
||||
None if options.buffer_verify_stdout => {
|
||||
let dir = temp_dir_for_stdout(options.temp_dir.as_deref());
|
||||
Ok(Self::BufferVerify {
|
||||
temp: SecureTempFile::create(&dir, "fcry-buffer")?,
|
||||
})
|
||||
}
|
||||
None => Ok(Self::Stdout(io::stdout())),
|
||||
Some(f) => {
|
||||
let final_path = PathBuf::from(f.as_ref());
|
||||
let mut tmp_path = final_path.clone();
|
||||
let name = tmp_path
|
||||
.file_name()
|
||||
.map(|n| n.to_os_string())
|
||||
.unwrap_or_default();
|
||||
let mut tmp_name = name;
|
||||
tmp_name.push(".tmp");
|
||||
tmp_path.set_file_name(tmp_name);
|
||||
let file = File::create(&tmp_path)?;
|
||||
Ok(Self::File {
|
||||
tmp_path,
|
||||
final_path,
|
||||
file: Some(file),
|
||||
committed: false,
|
||||
})
|
||||
if final_path.exists()
|
||||
&& !options.force
|
||||
&& !output_aliases_input(&final_path, options.input_file.as_deref())?
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!(
|
||||
"output file {} already exists (use --force to replace it)",
|
||||
final_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let dir = temp_dir_for_target(&final_path, options.temp_dir.as_deref());
|
||||
let prefix = file_name_prefix(&final_path);
|
||||
let temp = SecureTempFile::create(&dir, &prefix)?;
|
||||
Ok(Self::File { final_path, temp })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
if let Self::File {
|
||||
tmp_path,
|
||||
final_path,
|
||||
file,
|
||||
committed,
|
||||
} = &mut self
|
||||
{
|
||||
if let Some(mut f) = file.take() {
|
||||
f.flush()?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&*tmp_path, &*final_path)?;
|
||||
*committed = true;
|
||||
match &mut self {
|
||||
Self::Stdout(s) => s.flush()?,
|
||||
Self::BufferVerify { .. } => {}
|
||||
Self::File { .. } => {}
|
||||
}
|
||||
match self {
|
||||
Self::Stdout(_) => {}
|
||||
Self::BufferVerify { temp } => temp.copy_to_stdout()?,
|
||||
Self::File { final_path, temp } => temp.persist(&final_path)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -111,33 +275,16 @@ impl Write for OutSink {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Self::Stdout(s) => s.write(buf),
|
||||
Self::File { file, .. } => file.as_mut().expect("file taken before commit").write(buf),
|
||||
Self::BufferVerify { temp } => temp.file_mut().write(buf),
|
||||
Self::File { temp, .. } => temp.file_mut().write(buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
Self::Stdout(s) => s.flush(),
|
||||
Self::File { file, .. } => match file.as_mut() {
|
||||
Some(f) => f.flush(),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OutSink {
|
||||
fn drop(&mut self) {
|
||||
if let Self::File {
|
||||
tmp_path,
|
||||
committed,
|
||||
file,
|
||||
..
|
||||
} = self
|
||||
&& !*committed
|
||||
{
|
||||
file.take(); // close the file before unlink
|
||||
let _ = fs::remove_file(tmp_path);
|
||||
Self::BufferVerify { temp } => temp.file_mut().flush(),
|
||||
Self::File { temp, .. } => temp.file_mut().flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user