use std::fs;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(target_os = "windows")]
use std::time::SystemTime;
use std::time::{Duration, Instant};
use aead::stream::{DecryptorBE32, EncryptorBE32};
use aes_gcm_siv::{
aead::{Aead, KeyInit, Payload},
Aes256GcmSiv, Nonce,
};
use argon2::{Algorithm, Argon2, Params, Version};
use hkdf::Hkdf;
use rand_core::{OsRng, RngCore};
use sha2::Sha512;
use zeroize::{Zeroize, Zeroizing};
use crate::error::{CryptoError, CryptoResult};
pub const SALT_LEN: usize = 32;
pub const NONCE_LEN: usize = 12;
pub const HEADER_LEN: usize = 8 + SALT_LEN + NONCE_LEN;
pub const MAGIC: &[u8; 8] = b"VAULTX02";
pub const TAG_LEN: usize = 16;
pub const EXTENSION: &str = ".vx2";
pub const MAGIC_V3: &[u8; 8] = b"VAULTX03";
pub const SALT_V3_LEN: usize = 16;
pub const STREAM_NONCE_LEN: usize = 7;
pub const HEADER_V3_LEN: usize = 8 + SALT_V3_LEN + STREAM_NONCE_LEN;
pub const CHUNK_SIZE: usize = 1_048_576;
pub const MAX_V3_FILE_SIZE: u64 = (1u64 << 32) * CHUNK_SIZE as u64;
pub const MAGIC_V4: &[u8; 8] = b"VAULTX04";
pub const HEADER_V4_LEN: usize = HEADER_V3_LEN;
pub const MAX_V4_FILE_SIZE: u64 = MAX_V3_FILE_SIZE;
pub const MAX_FILE_SIZE: u64 = 8_000_000_000;
pub const MAX_ENCRYPTED_FILE_SIZE: u64 = MAX_FILE_SIZE + HEADER_LEN as u64 + TAG_LEN as u64;
pub const MIN_PASSWORD_LEN: usize = 8;
pub trait ProgressReporter: Send + Sync {
fn report(&self, progress: f32, message: &str);
}
fn source_file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from("file"))
}
pub fn default_encrypt_output_name(path: &Path) -> String {
format!("{}{}", source_file_name(path), EXTENSION)
}
pub fn default_decrypt_output_name(path: &Path) -> String {
let name = source_file_name(path);
if name.len() >= EXTENSION.len()
&& name[name.len() - EXTENSION.len()..].eq_ignore_ascii_case(EXTENSION)
{
let stripped = &name[..name.len() - EXTENSION.len()];
if stripped.is_empty() {
String::from("decrypted")
} else {
stripped.to_owned()
}
} else {
name
}
}
pub struct ThrottledReporter<'a> {
inner: &'a dyn ProgressReporter,
last_progress: AtomicU32,
last_time: Mutex<Option<Instant>>,
last_message: Mutex<String>,
}
impl<'a> ThrottledReporter<'a> {
pub fn new(inner: &'a dyn ProgressReporter) -> Self {
Self {
inner,
last_progress: AtomicU32::new(f32::to_bits(-1.0)),
last_time: Mutex::new(None),
last_message: Mutex::new(String::new()),
}
}
}
impl<'a> ProgressReporter for ThrottledReporter<'a> {
fn report(&self, progress: f32, message: &str) {
let now = Instant::now();
let last_progress = f32::from_bits(self.last_progress.load(Ordering::Relaxed));
let (last_message, last_time) = {
let message_guard = self.last_message.lock().unwrap_or_else(|e| e.into_inner());
let time_guard = self.last_time.lock().unwrap_or_else(|e| e.into_inner());
(message_guard.clone(), *time_guard)
};
let should_report = match last_time {
Some(last_time) => {
let elapsed_ms = now.duration_since(last_time).as_millis();
(progress - last_progress).abs() > 0.01
|| elapsed_ms > 100
|| last_message != message
}
None => true,
};
if should_report {
self.inner.report(progress, message);
*self
.last_message
.lock()
.unwrap_or_else(|poison| poison.into_inner()) = message.to_owned();
*self
.last_time
.lock()
.unwrap_or_else(|poison| poison.into_inner()) = Some(now);
self.last_progress
.store(progress.to_bits(), Ordering::Relaxed);
}
}
}
fn derive_key(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
let mut final_key = Zeroizing::new(vec![0u8; 32]);
let mut intermediate = Zeroizing::new(vec![0u8; 64]);
let params = Params::new(262_144, 3, 4, Some(64))
.map_err(|error| CryptoError::Argon2Failed(error.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
argon2
.hash_password_into(password, salt, &mut intermediate)
.map_err(|error| CryptoError::Argon2Failed(error.to_string()))?;
let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
hkdf.expand(b"VAULTX02_AES_256_GCM_SIV", &mut final_key)
.map_err(|error| CryptoError::HkdfFailed(error.to_string()))?;
intermediate.zeroize();
Ok(final_key)
}
fn derive_key_v3(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
let mut final_key = Zeroizing::new(vec![0u8; 32]);
let mut intermediate = Zeroizing::new(vec![0u8; 64]);
let params = Params::new(262_144, 3, 4, Some(64))
.map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
argon2
.hash_password_into(password, salt, &mut intermediate)
.map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
hkdf.expand(b"VAULTX03_AES_256_GCM_SIV_STREAM", &mut final_key)
.map_err(|e| CryptoError::HkdfFailed(e.to_string()))?;
intermediate.zeroize();
Ok(final_key)
}
fn derive_key_v4(password: &[u8], salt: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
let mut final_key = Zeroizing::new(vec![0u8; 32]);
let mut intermediate = Zeroizing::new(vec![0u8; 64]);
let params = Params::new(262_144, 3, 4, Some(64))
.map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
argon2
.hash_password_into(password, salt, &mut intermediate)
.map_err(|e| CryptoError::Argon2Failed(e.to_string()))?;
let hkdf = Hkdf::<Sha512>::new(None, &intermediate);
hkdf.expand(b"VAULTX04_AES_256_GCM_SIV_STREAM_AAD", &mut final_key)
.map_err(|e| CryptoError::HkdfFailed(e.to_string()))?;
intermediate.zeroize();
Ok(final_key)
}
fn build_aad(salt: &[u8], nonce: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(SALT_LEN + NONCE_LEN);
aad.extend_from_slice(salt);
aad.extend_from_slice(nonce);
aad
}
fn build_aad_v4(salt: &[u8], stream_nonce: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(8 + SALT_V3_LEN + STREAM_NONCE_LEN);
aad.extend_from_slice(MAGIC_V4);
aad.extend_from_slice(salt);
aad.extend_from_slice(stream_nonce);
aad
}
fn open_regular_file(path: &Path) -> CryptoResult<(fs::File, fs::Metadata)> {
let file = fs::File::open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(CryptoError::NotAFile(path.to_path_buf()));
}
Ok((file, metadata))
}
fn normalize_destination_path(path: &Path) -> CryptoResult<PathBuf> {
let file_name = path
.file_name()
.ok_or_else(|| CryptoError::InvalidDestination(path.to_path_buf()))?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let canonical_parent = fs::canonicalize(parent)?;
if !canonical_parent.is_dir() {
return Err(CryptoError::InvalidDestination(path.to_path_buf()));
}
Ok(canonical_parent.join(file_name))
}
#[cfg(target_os = "windows")]
fn same_path(a: &Path, b: &Path) -> bool {
a.to_string_lossy()
.eq_ignore_ascii_case(&b.to_string_lossy())
}
#[cfg(not(target_os = "windows"))]
fn same_path(a: &Path, b: &Path) -> bool {
a == b
}
fn validate_destination_path(src: &Path, dest: &Path, overwrite: bool) -> CryptoResult<PathBuf> {
let canonical_src = fs::canonicalize(src)?;
let canonical_dest = normalize_destination_path(dest)?;
if same_path(&canonical_src, &canonical_dest) {
return Err(CryptoError::SourceAndDestinationSame(canonical_dest));
}
match fs::symlink_metadata(&canonical_dest) {
Ok(_) if overwrite => fs::remove_file(&canonical_dest)?,
Ok(_) => return Err(CryptoError::FileAlreadyExists(canonical_dest)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
Ok(canonical_dest)
}
fn read_limited_file(file: fs::File, limit: u64) -> std::io::Result<Vec<u8>> {
let mut reader = file.take(limit + 1);
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
Ok(buffer)
}
fn persist_temp_file(tmp: &Path, dest: &Path) -> CryptoResult<()> {
#[cfg(unix)]
{
match fs::hard_link(tmp, dest) {
Ok(()) => {
let _ = fs::remove_file(tmp);
Ok(())
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(tmp);
Err(CryptoError::FileAlreadyExists(dest.to_path_buf()))
}
Err(error) => {
let _ = fs::remove_file(tmp);
Err(error.into())
}
}
}
#[cfg(not(unix))]
{
match fs::rename(tmp, dest) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(tmp);
Err(CryptoError::FileAlreadyExists(dest.to_path_buf()))
}
Err(error) => {
let _ = fs::remove_file(tmp);
Err(error.into())
}
}
}
}
fn tmp_path(dest: &Path) -> PathBuf {
let mut rng_bytes = [0u8; 8];
OsRng.fill_bytes(&mut rng_bytes);
let suffix = hex::encode(rng_bytes);
let mut tmp = dest.to_path_buf();
let name = dest.file_name().unwrap_or_default().to_string_lossy();
tmp.set_file_name(format!("{name}.{suffix}.tmp"));
tmp
}
#[allow(clippy::type_complexity)]
pub fn encrypt_bytes(
plaintext: &[u8],
password: &[u8],
) -> CryptoResult<(Zeroizing<Vec<u8>>, [u8; SALT_LEN], [u8; NONCE_LEN])> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
let mut salt = [0u8; SALT_LEN];
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_key(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_aad(&salt, &nonce_bytes);
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: &aad,
},
)
.map_err(|error| CryptoError::EncryptionFailed(error.to_string()))?;
Ok((Zeroizing::new(ciphertext), salt, nonce_bytes))
}
pub fn decrypt_bytes(
ciphertext: &[u8],
password: &[u8],
salt: &[u8],
nonce_bytes: &[u8],
) -> CryptoResult<Zeroizing<Vec<u8>>> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
if salt.len() != SALT_LEN {
return Err(CryptoError::InvalidSaltLength {
expected: SALT_LEN,
actual: salt.len(),
});
}
if nonce_bytes.len() != NONCE_LEN {
return Err(CryptoError::InvalidNonceLength {
expected: NONCE_LEN,
actual: nonce_bytes.len(),
});
}
let key = derive_key(password, salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let nonce = Nonce::from_slice(nonce_bytes);
let aad = build_aad(salt, nonce_bytes);
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: &aad,
},
)
.map_err(|_| CryptoError::DecryptionFailed)?;
Ok(Zeroizing::new(plaintext))
}
pub fn parse_header(raw: &[u8]) -> CryptoResult<(&[u8], &[u8], &[u8])> {
if raw.len() < HEADER_LEN + TAG_LEN {
return Err(CryptoError::FileTooSmall);
}
if &raw[..8] != MAGIC {
return Err(CryptoError::InvalidMagic);
}
let salt = &raw[8..40];
let nonce = &raw[40..52];
let ciphertext = &raw[52..];
Ok((salt, nonce, ciphertext))
}
pub fn encrypt_file(
src: &Path,
dest: &Path,
overwrite: bool,
password: &[u8],
reporter: &dyn ProgressReporter,
cancel_flag: Option<&Arc<AtomicBool>>,
) -> CryptoResult<PathBuf> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
let (mut file, source_metadata) = open_regular_file(src)?;
let dest = validate_destination_path(src, dest, overwrite)?;
let source_len = source_metadata.len();
if source_len > MAX_V4_FILE_SIZE {
return Err(CryptoError::FileTooLarge {
size_gb: source_len as f64 / 1_000_000_000.0,
max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
});
}
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut stream_nonce);
reporter.report(0.05, "Deriving encryption key (Argon2id)...");
let key = derive_key_v4(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut encryptor = EncryptorBE32::from_aead(cipher, (&stream_nonce).into());
let aad = build_aad_v4(&salt, &stream_nonce);
let tmp = tmp_path(&dest);
let write_result = (|| -> CryptoResult<()> {
#[cfg(unix)]
let mut out = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut out = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
out.write_all(MAGIC_V4)?;
out.write_all(&salt)?;
out.write_all(&stream_nonce)?;
let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
let mut bytes_done: u64 = 0;
let mut chunk_counter: u64 = 0;
const IO_GOVENER_INTERVAL: u64 = 100;
loop {
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
let n = read_exact_or_eof(&mut file, buf.as_mut_slice())?;
if n == 0 {
let ct = encryptor
.encrypt_last(Payload {
msg: &[][..],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
out.write_all(&ct)?;
break;
}
bytes_done += n as u64;
let frac = if source_len > 0 {
0.10 + 0.85 * (bytes_done as f32 / source_len as f32)
} else {
0.95
};
let mut peek = [0u8; 1];
let peeked = file.read(&mut peek)?;
if peeked == 0 {
reporter.report(frac, "Encrypting final chunk...");
let ct = encryptor
.encrypt_last(Payload {
msg: &buf[..n],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
out.write_all(&ct)?;
break;
} else {
reporter.report(frac, "Encrypting...");
let ct = encryptor
.encrypt_next(Payload {
msg: &buf[..n],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
out.write_all(&ct)?;
chunk_counter += 1;
if chunk_counter.is_multiple_of(IO_GOVENER_INTERVAL) {
out.sync_data()?;
std::thread::sleep(Duration::from_millis(5));
}
file.seek(std::io::SeekFrom::Current(-1))?;
}
}
out.sync_all()?;
Ok(())
})();
if let Err(error) = write_result {
let _ = fs::remove_file(&tmp);
return Err(error);
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
persist_temp_file(&tmp, &dest)?;
reporter.report(1.00, "Encryption complete.");
Ok(dest)
}
fn read_exact_or_eof(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match reader.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(total)
}
pub fn decrypt_file(
src: &Path,
dest: &Path,
overwrite: bool,
password: &[u8],
reporter: &dyn ProgressReporter,
cancel_flag: Option<&Arc<AtomicBool>>,
) -> CryptoResult<PathBuf> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
return Err(CryptoError::Cancelled);
}
}
let mut magic_buf = [0u8; 8];
{
let mut f = fs::File::open(src)?;
f.read_exact(&mut magic_buf)
.map_err(|_| CryptoError::FileTooSmall)?;
}
if &magic_buf == MAGIC {
decrypt_file_legacy(src, dest, overwrite, password, reporter, cancel_flag)
} else if &magic_buf == MAGIC_V4 {
decrypt_file_streaming_v4(src, dest, overwrite, password, reporter, cancel_flag)
} else if &magic_buf == MAGIC_V3 {
decrypt_file_streaming(src, dest, overwrite, password, reporter, cancel_flag)
} else {
Err(CryptoError::InvalidMagic)
}
}
fn decrypt_file_legacy(
src: &Path,
dest: &Path,
overwrite: bool,
password: &[u8],
reporter: &dyn ProgressReporter,
cancel_flag: Option<&Arc<AtomicBool>>,
) -> CryptoResult<PathBuf> {
let (file, source_metadata) = open_regular_file(src)?;
let dest = validate_destination_path(src, dest, overwrite)?;
let source_len = source_metadata.len();
#[cfg(target_os = "windows")]
let (src_accessed, src_modified) = (
source_metadata.accessed().ok(),
source_metadata.modified().ok(),
);
if source_len > MAX_ENCRYPTED_FILE_SIZE {
return Err(CryptoError::FileTooLarge {
size_gb: source_len as f64 / 1_000_000_000.0,
max_gb: MAX_ENCRYPTED_FILE_SIZE as f64 / 1_000_000_000.0,
});
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
return Err(CryptoError::Cancelled);
}
}
eprintln!("[INFO] VAULTX02 (V2) format detected. Consider re-encrypting with V3 for streaming support and no memory limits.");
reporter.report(0.10, "Reading encrypted file (legacy V2)...");
let raw = read_limited_file(file, 1_073_741_824u64)?;
if raw.len() as u64 > 1_073_741_824u64 {
return Err(CryptoError::LegacyFileTooLarge);
}
let (salt, nonce, ciphertext) = parse_header(&raw)?;
reporter.report(0.20, "Deriving decryption key (Argon2id)...");
let key = derive_key(password, salt)?;
reporter.report(0.50, "Decrypting data (AES-256-GCM-SIV)...");
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let aad = build_aad(salt, nonce);
let plaintext = Zeroizing::new(
cipher
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: ciphertext,
aad: &aad,
},
)
.map_err(|_| CryptoError::DecryptionFailed)?,
);
let tmp = tmp_path(&dest);
reporter.report(0.75, "Writing decrypted file...");
let write_result = (|| -> CryptoResult<()> {
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
file.write_all(&plaintext)?;
file.sync_all()?;
Ok(())
})();
if let Err(error) = write_result {
let _ = fs::remove_file(&tmp);
return Err(error);
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
persist_temp_file(&tmp, &dest)?;
#[cfg(target_os = "windows")]
{
if let Ok(file) = fs::File::open(&dest) {
let times = fs::FileTimes::new()
.set_accessed(src_accessed.unwrap_or(SystemTime::now()))
.set_modified(src_modified.unwrap_or(SystemTime::now()));
let _ = file.set_times(times);
}
}
reporter.report(1.00, "Decryption complete.");
Ok(dest)
}
fn decrypt_file_streaming(
src: &Path,
dest: &Path,
overwrite: bool,
password: &[u8],
reporter: &dyn ProgressReporter,
cancel_flag: Option<&Arc<AtomicBool>>,
) -> CryptoResult<PathBuf> {
let (mut file, source_metadata) = open_regular_file(src)?;
let dest = validate_destination_path(src, dest, overwrite)?;
let file_len = source_metadata.len();
#[cfg(target_os = "windows")]
let (src_accessed, src_modified) = (
source_metadata.accessed().ok(),
source_metadata.modified().ok(),
);
if file_len < HEADER_V3_LEN as u64 + TAG_LEN as u64 {
return Err(CryptoError::FileTooSmall);
}
file.seek(std::io::SeekFrom::Start(8))?;
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
file.read_exact(&mut salt)?;
file.read_exact(&mut stream_nonce)?;
reporter.report(0.05, "Deriving decryption key (Argon2id)...");
let key = derive_key_v3(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
let encrypted_body = file_len - HEADER_V3_LEN as u64;
let enc_chunk_size = CHUNK_SIZE + TAG_LEN;
let tmp = tmp_path(&dest);
let write_result = (|| -> CryptoResult<()> {
#[cfg(unix)]
let mut out = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut out = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
let mut buf = Zeroizing::new(vec![0u8; enc_chunk_size]);
let mut bytes_done: u64 = 0;
let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
loop {
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
let n = read_exact_or_eof(&mut file, &mut buf)?;
if n == 0 {
break;
}
if let Some(prev) = last_chunk.take() {
let frac = 0.10 + 0.85 * (bytes_done as f32 / encrypted_body as f32);
reporter.report(frac, "Decrypting...");
let pt = Zeroizing::new(
decryptor
.decrypt_next(prev.as_slice())
.map_err(|_| CryptoError::DecryptionFailed)?,
);
out.write_all(pt.as_slice())?;
}
bytes_done += n as u64;
last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
}
if let Some(final_data) = last_chunk {
reporter.report(0.95, "Decrypting final chunk...");
let pt = Zeroizing::new(
decryptor
.decrypt_last(final_data.as_slice())
.map_err(|_| CryptoError::DecryptionFailed)?,
);
out.write_all(pt.as_slice())?;
}
out.sync_all()?;
Ok(())
})();
if let Err(error) = write_result {
let _ = fs::remove_file(&tmp);
return Err(error);
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
persist_temp_file(&tmp, &dest)?;
#[cfg(target_os = "windows")]
{
if let Ok(file) = fs::File::open(&dest) {
let times = fs::FileTimes::new()
.set_accessed(src_accessed.unwrap_or(SystemTime::now()))
.set_modified(src_modified.unwrap_or(SystemTime::now()));
let _ = file.set_times(times);
}
}
reporter.report(1.00, "Decryption complete.");
Ok(dest)
}
fn decrypt_file_streaming_v4(
src: &Path,
dest: &Path,
overwrite: bool,
password: &[u8],
reporter: &dyn ProgressReporter,
cancel_flag: Option<&Arc<AtomicBool>>,
) -> CryptoResult<PathBuf> {
let (mut file, source_metadata) = open_regular_file(src)?;
let dest = validate_destination_path(src, dest, overwrite)?;
let file_len = source_metadata.len();
#[cfg(target_os = "windows")]
let (src_accessed, src_modified) = (
source_metadata.accessed().ok(),
source_metadata.modified().ok(),
);
if file_len < HEADER_V4_LEN as u64 + TAG_LEN as u64 {
return Err(CryptoError::FileTooSmall);
}
file.seek(std::io::SeekFrom::Start(8))?;
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
file.read_exact(&mut salt)?;
file.read_exact(&mut stream_nonce)?;
reporter.report(0.05, "Deriving decryption key (Argon2id)...");
let key = derive_key_v4(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
let aad = build_aad_v4(&salt, &stream_nonce);
let encrypted_body = file_len - HEADER_V4_LEN as u64;
let enc_chunk_size = CHUNK_SIZE + TAG_LEN;
let tmp = tmp_path(&dest);
let write_result = (|| -> CryptoResult<()> {
#[cfg(unix)]
let mut out = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut out = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
let mut buf = Zeroizing::new(vec![0u8; enc_chunk_size]);
let mut bytes_done: u64 = 0;
let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
loop {
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
let n = read_exact_or_eof(&mut file, &mut buf)?;
if n == 0 {
break;
}
if let Some(prev) = last_chunk.take() {
let frac = 0.10 + 0.85 * (bytes_done as f32 / encrypted_body as f32);
reporter.report(frac, "Decrypting...");
let pt = Zeroizing::new(
decryptor
.decrypt_next(Payload {
msg: prev.as_slice(),
aad: &aad,
})
.map_err(|_| CryptoError::DecryptionFailed)?,
);
out.write_all(pt.as_slice())?;
}
bytes_done += n as u64;
last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
}
if let Some(final_data) = last_chunk {
reporter.report(0.95, "Decrypting final chunk...");
let pt = Zeroizing::new(
decryptor
.decrypt_last(Payload {
msg: final_data.as_slice(),
aad: &aad,
})
.map_err(|_| CryptoError::DecryptionFailed)?,
);
out.write_all(pt.as_slice())?;
}
out.sync_all()?;
Ok(())
})();
if let Err(error) = write_result {
let _ = fs::remove_file(&tmp);
return Err(error);
}
if let Some(flag) = cancel_flag {
if flag.load(Ordering::SeqCst) {
let _ = fs::remove_file(&tmp);
return Err(CryptoError::Cancelled);
}
}
persist_temp_file(&tmp, &dest)?;
#[cfg(target_os = "windows")]
{
if let Ok(file) = fs::File::open(&dest) {
let times = fs::FileTimes::new()
.set_accessed(src_accessed.unwrap_or(SystemTime::now()))
.set_modified(src_modified.unwrap_or(SystemTime::now()));
let _ = file.set_times(times);
}
}
reporter.report(1.00, "Decryption complete.");
Ok(dest)
}
pub fn secure_wipe(path: &Path) -> CryptoResult<()> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() {
return Err(CryptoError::NotAFile(path.to_path_buf()));
}
let len = metadata.len();
let mut file = fs::OpenOptions::new().write(true).open(path)?;
let mut buffer = vec![0u8; 64 * 1024];
for _ in 0..3 {
file.seek(std::io::SeekFrom::Start(0))?;
let mut written = 0;
while written < len {
let to_write = std::cmp::min(buffer.len() as u64, len - written) as usize;
OsRng.fill_bytes(&mut buffer[..to_write]);
file.write_all(&buffer[..to_write])?;
written += to_write as u64;
}
file.sync_all()?;
}
#[cfg(target_os = "windows")]
{
let epoch = SystemTime::UNIX_EPOCH;
let times = fs::FileTimes::new().set_accessed(epoch).set_modified(epoch);
let _ = file.set_times(times);
}
drop(file);
for _ in 0..8 {
let mut random_name = [0u8; 16];
OsRng.fill_bytes(&mut random_name);
let renamed_path = path.with_file_name(hex::encode(random_name));
match fs::rename(path, &renamed_path) {
Ok(()) => {
fs::remove_file(renamed_path)?;
return Ok(());
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"failed to generate a unique wipe filename",
)
.into())
}
pub fn encrypt_stream<R: Read + Seek, W: Write>(
reader: &mut R,
writer: &mut W,
password: &[u8],
source_size: Option<u64>,
reporter: &dyn ProgressReporter,
) -> CryptoResult<()> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
if let Some(size) = source_size {
if size > MAX_V4_FILE_SIZE {
return Err(CryptoError::FileTooLarge {
size_gb: size as f64 / 1_000_000_000.0,
max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
});
}
}
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut stream_nonce);
reporter.report(0.05, "Deriving encryption key (Argon2id)...");
let key = derive_key_v4(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut encryptor = EncryptorBE32::from_aead(cipher, (&stream_nonce).into());
let aad = build_aad_v4(&salt, &stream_nonce);
writer.write_all(MAGIC_V4)?;
writer.write_all(&salt)?;
writer.write_all(&stream_nonce)?;
let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE]);
let mut bytes_done: u64 = 0;
let mut chunk_counter: u64 = 0;
const IO_GOVERNOR_INTERVAL: u64 = 100;
loop {
let n = read_exact_or_eof(reader, buf.as_mut_slice())?;
if n == 0 {
let ct = encryptor
.encrypt_last(Payload {
msg: &[][..],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
writer.write_all(&ct)?;
break;
}
bytes_done += n as u64;
let frac = if let Some(total) = source_size {
if total > 0 {
0.10 + 0.85 * (bytes_done as f32 / total as f32)
} else {
0.95
}
} else {
0.50
};
let mut peek = [0u8; 1];
let peeked = reader.read(&mut peek)?;
if peeked == 0 {
reporter.report(frac, "Encrypting final chunk...");
let ct = encryptor
.encrypt_last(Payload {
msg: &buf[..n],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
writer.write_all(&ct)?;
break;
} else {
reporter.report(frac, "Encrypting...");
let ct = encryptor
.encrypt_next(Payload {
msg: &buf[..n],
aad: &aad,
})
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
writer.write_all(&ct)?;
chunk_counter += 1;
if chunk_counter >= u32::MAX as u64 {
return Err(CryptoError::FileTooLarge {
size_gb: (chunk_counter * CHUNK_SIZE as u64) as f64 / 1_000_000_000.0,
max_gb: MAX_V4_FILE_SIZE as f64 / 1_000_000_000.0,
});
}
if chunk_counter.is_multiple_of(IO_GOVERNOR_INTERVAL) {
writer.flush()?;
std::thread::sleep(Duration::from_millis(5));
}
reader.seek(std::io::SeekFrom::Current(-1))?;
}
}
writer.flush()?;
reporter.report(1.00, "Encryption complete.");
Ok(())
}
pub fn decrypt_stream<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
password: &[u8],
total_size: Option<u64>,
reporter: &dyn ProgressReporter,
) -> CryptoResult<()> {
if password.len() < MIN_PASSWORD_LEN {
return Err(CryptoError::PassphraseTooShort(MIN_PASSWORD_LEN));
}
let mut magic = [0u8; 8];
reader
.read_exact(&mut magic)
.map_err(|error| match error.kind() {
std::io::ErrorKind::UnexpectedEof => CryptoError::FileTooSmall,
_ => CryptoError::Io(error),
})?;
if &magic == MAGIC_V4 {
decrypt_stream_v4(reader, writer, password, total_size, reporter)
} else if &magic == MAGIC_V3 {
decrypt_stream_v3(reader, writer, password, total_size, reporter)
} else if &magic == MAGIC {
decrypt_stream_v2(reader, writer, password, reporter)
} else {
Err(CryptoError::InvalidMagic)
}
}
fn decrypt_stream_v3<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
password: &[u8],
total_size: Option<u64>,
reporter: &dyn ProgressReporter,
) -> CryptoResult<()> {
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
reader.read_exact(&mut salt)?;
reader.read_exact(&mut stream_nonce)?;
reporter.report(0.05, "Deriving decryption key (Argon2id)...");
let key = derive_key_v3(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE + TAG_LEN]);
let mut bytes_done: u64 = 0;
let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
loop {
let n = read_exact_or_eof(reader, &mut buf)?;
if n == 0 {
break;
}
if let Some(prev) = last_chunk.take() {
let frac = if let Some(total) = total_size {
if total > 0 {
0.10 + 0.85 * (bytes_done as f32 / total as f32)
} else {
0.50
}
} else {
0.50
};
reporter.report(frac, "Decrypting...");
let pt = Zeroizing::new(
decryptor
.decrypt_next(prev.as_slice())
.map_err(|_| CryptoError::DecryptionFailed)?,
);
writer.write_all(pt.as_slice())?;
}
bytes_done += n as u64;
last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
}
if let Some(final_data) = last_chunk {
reporter.report(0.95, "Decrypting final chunk...");
let pt = Zeroizing::new(
decryptor
.decrypt_last(final_data.as_slice())
.map_err(|_| CryptoError::DecryptionFailed)?,
);
writer.write_all(pt.as_slice())?;
} else {
return Err(CryptoError::FileTooSmall);
}
writer.flush()?;
reporter.report(1.00, "Decryption complete.");
Ok(())
}
fn decrypt_stream_v4<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
password: &[u8],
total_size: Option<u64>,
reporter: &dyn ProgressReporter,
) -> CryptoResult<()> {
let mut salt = [0u8; SALT_V3_LEN];
let mut stream_nonce = [0u8; STREAM_NONCE_LEN];
reader.read_exact(&mut salt)?;
reader.read_exact(&mut stream_nonce)?;
reporter.report(0.05, "Deriving decryption key (Argon2id)...");
let key = derive_key_v4(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
let mut decryptor = DecryptorBE32::from_aead(cipher, (&stream_nonce).into());
let aad = build_aad_v4(&salt, &stream_nonce);
let mut buf = Zeroizing::new(vec![0u8; CHUNK_SIZE + TAG_LEN]);
let mut bytes_done: u64 = 0;
let mut last_chunk: Option<Zeroizing<Vec<u8>>> = None;
loop {
let n = read_exact_or_eof(reader, &mut buf)?;
if n == 0 {
break;
}
if let Some(prev) = last_chunk.take() {
let frac = if let Some(total) = total_size {
if total > 0 {
0.10 + 0.85 * (bytes_done as f32 / total as f32)
} else {
0.50
}
} else {
0.50
};
reporter.report(frac, "Decrypting...");
let pt = Zeroizing::new(
decryptor
.decrypt_next(Payload {
msg: prev.as_slice(),
aad: &aad,
})
.map_err(|_| CryptoError::DecryptionFailed)?,
);
writer.write_all(pt.as_slice())?;
}
bytes_done += n as u64;
last_chunk = Some(Zeroizing::new(buf[..n].to_vec()));
}
if let Some(final_data) = last_chunk {
reporter.report(0.95, "Decrypting final chunk...");
let pt = Zeroizing::new(
decryptor
.decrypt_last(Payload {
msg: final_data.as_slice(),
aad: &aad,
})
.map_err(|_| CryptoError::DecryptionFailed)?,
);
writer.write_all(pt.as_slice())?;
} else {
return Err(CryptoError::FileTooSmall);
}
writer.flush()?;
reporter.report(1.00, "Decryption complete.");
Ok(())
}
fn decrypt_stream_v2<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
password: &[u8],
reporter: &dyn ProgressReporter,
) -> CryptoResult<()> {
eprintln!("[INFO] VAULTX02 (V2) format detected. Consider re-encrypting with V3 for streaming support and no memory limits.");
let mut salt = [0u8; SALT_LEN];
let mut nonce = [0u8; NONCE_LEN];
reader.read_exact(&mut salt)?;
reader.read_exact(&mut nonce)?;
reporter.report(0.05, "Deriving decryption key (Argon2id)...");
let key = derive_key(password, &salt)?;
let cipher = Aes256GcmSiv::new(key.as_slice().into());
const V2_STREAM_MAX_BYTES: u64 = 1_073_741_824;
let mut ciphertext = Vec::new();
reader
.take(V2_STREAM_MAX_BYTES + 1)
.read_to_end(&mut ciphertext)?;
if ciphertext.len() as u64 > V2_STREAM_MAX_BYTES {
return Err(CryptoError::LegacyFileTooLarge);
}
let aad = build_aad(&salt, &nonce);
reporter.report(0.50, "Decrypting data (AES-256-GCM-SIV)...");
let plaintext = Zeroizing::new(
cipher
.decrypt(
Nonce::from_slice(&nonce),
Payload {
msg: &ciphertext,
aad: &aad,
},
)
.map_err(|_| CryptoError::DecryptionFailed)?,
);
writer.write_all(plaintext.as_slice())?;
writer.flush()?;
reporter.report(1.00, "Decryption complete.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct TestReporter;
impl ProgressReporter for TestReporter {
fn report(&self, _progress: f32, _message: &str) {}
}
fn unique_test_dir() -> PathBuf {
std::env::temp_dir().join(format!(
"neuron_test_{}",
rand_core::RngCore::next_u64(&mut rand_core::OsRng)
))
}
#[test]
fn test_encrypt_decrypt_bytes() {
let plaintext = b"Hello, security audit!";
let password = b"supersecretpassword";
let (ciphertext, salt, nonce) = encrypt_bytes(plaintext, password).unwrap();
let decrypted = decrypt_bytes(&ciphertext, password, &salt, &nonce).unwrap();
assert_eq!(plaintext, decrypted.as_slice());
}
#[test]
fn test_encrypt_decrypt_file() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("src.txt");
let encrypted_path = tmp_dir.join("dest.vx2");
let decrypted_path = tmp_dir.join("final.txt");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src_path, b"File content for testing").unwrap();
encrypt_file(
&src_path,
&encrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
assert!(encrypted_path.exists());
decrypt_file(
&encrypted_path,
&decrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
assert!(decrypted_path.exists());
let final_content = fs::read(decrypted_path).unwrap();
assert_eq!(final_content, b"File content for testing");
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_password_too_short() {
let plaintext = b"some data";
let password = b"short";
let result = encrypt_bytes(plaintext, password);
assert!(matches!(result, Err(CryptoError::PassphraseTooShort(_))));
}
#[test]
fn test_invalid_magic() {
let mut raw = vec![0u8; 100];
raw[0..8].copy_from_slice(b"NOTMAGIC");
let result = parse_header(&raw);
assert!(matches!(result, Err(CryptoError::InvalidMagic)));
}
#[test]
fn test_default_output_names_strip_one_vx2_suffix() {
assert_eq!(
default_encrypt_output_name(Path::new("report.pdf")),
"report.pdf.vx2"
);
assert_eq!(
default_decrypt_output_name(Path::new("report.pdf.vx2")),
"report.pdf"
);
assert_eq!(
default_decrypt_output_name(Path::new("archive.vx2.vx2")),
"archive.vx2"
);
assert_eq!(default_decrypt_output_name(Path::new(".vx2")), "decrypted");
}
#[test]
fn test_decrypt_bytes_rejects_invalid_nonce_length() {
let result = decrypt_bytes(
b"ciphertext",
b"supersecretpassword",
&[1u8; SALT_LEN],
&[0u8; 8],
);
assert!(matches!(
result,
Err(CryptoError::InvalidNonceLength {
expected: NONCE_LEN,
actual: 8
})
));
}
#[test]
fn test_decrypt_bytes_rejects_short_password() {
let result = decrypt_bytes(b"ciphertext", b"short", &[1u8; SALT_LEN], &[0u8; NONCE_LEN]);
assert!(matches!(result, Err(CryptoError::PassphraseTooShort(_))));
}
#[test]
fn test_encrypt_file_rejects_existing_destination() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("src.txt");
let dest_path = tmp_dir.join("dest.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src_path, b"secret").unwrap();
fs::write(&dest_path, b"already here").unwrap();
let result = encrypt_file(
&src_path,
&dest_path,
false,
password.as_bytes(),
&reporter,
None,
);
assert!(matches!(result, Err(CryptoError::FileAlreadyExists(_))));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_encrypt_file_overwrites_existing_destination_with_force() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("src.txt");
let dest_path = tmp_dir.join("dest.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src_path, b"secret").unwrap();
fs::write(&dest_path, b"already here").unwrap();
let result = encrypt_file(
&src_path,
&dest_path,
true,
password.as_bytes(),
&reporter,
None,
);
assert!(result.is_ok());
assert!(dest_path.exists());
assert_ne!(fs::read(&dest_path).unwrap(), b"already here");
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_encrypt_file_rejects_same_source_and_destination() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("src.txt");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src_path, b"secret").unwrap();
let result = encrypt_file(
&src_path,
&src_path,
false,
password.as_bytes(),
&reporter,
None,
);
assert!(matches!(
result,
Err(CryptoError::SourceAndDestinationSame(_))
));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_stream_roundtrip_multi_chunk() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("big.bin");
let encrypted_path = tmp_dir.join("big.vx2");
let decrypted_path = tmp_dir.join("big_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255)
.cycle()
.take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
.collect();
fs::write(&src_path, &data).unwrap();
encrypt_file(
&src_path,
&encrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
let header = fs::read(&encrypted_path).unwrap();
assert_eq!(&header[..8], MAGIC_V4);
decrypt_file(
&encrypted_path,
&decrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
let result = fs::read(&decrypted_path).unwrap();
assert_eq!(result, data);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_stream_roundtrip_exact_chunk() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("exact.bin");
let encrypted_path = tmp_dir.join("exact.vx2");
let decrypted_path = tmp_dir.join("exact_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data = vec![42u8; CHUNK_SIZE];
fs::write(&src_path, &data).unwrap();
encrypt_file(
&src_path,
&encrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
decrypt_file(
&encrypted_path,
&decrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
assert_eq!(fs::read(&decrypted_path).unwrap(), data);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_wrong_password() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src_path = tmp_dir.join("secret.txt");
let encrypted_path = tmp_dir.join("secret.vx2");
let decrypted_path = tmp_dir.join("secret_dec.txt");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let wrong = "y".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src_path, b"top secret data").unwrap();
encrypt_file(
&src_path,
&encrypted_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
let result = decrypt_file(
&encrypted_path,
&decrypted_path,
false,
wrong.as_bytes(),
&reporter,
None,
);
assert!(result.is_err());
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_stream_decrypt_rejects_header_only_input() {
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let mut input = Vec::new();
input.extend_from_slice(MAGIC_V3);
input.extend_from_slice(&[0u8; SALT_V3_LEN]);
input.extend_from_slice(&[0u8; STREAM_NONCE_LEN]);
let mut reader = std::io::Cursor::new(input);
let mut output = Vec::new();
let result = decrypt_stream(
&mut reader,
&mut output,
password.as_bytes(),
None,
&reporter,
);
assert!(matches!(result, Err(CryptoError::FileTooSmall)));
assert!(output.is_empty());
}
#[test]
fn test_v2_backward_compat() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let plaintext = b"Legacy V2 content";
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let (ciphertext, salt, nonce) = encrypt_bytes(plaintext, password.as_bytes()).unwrap();
let v2_path = tmp_dir.join("legacy.vx2");
{
let mut f = fs::File::create(&v2_path).unwrap();
f.write_all(MAGIC).unwrap();
f.write_all(&salt).unwrap();
f.write_all(&nonce).unwrap();
f.write_all(&ciphertext).unwrap();
f.sync_all().unwrap();
}
let dec_path = tmp_dir.join("legacy_dec.txt");
decrypt_file(
&v2_path,
&dec_path,
false,
password.as_bytes(),
&reporter,
None,
)
.unwrap();
assert_eq!(fs::read(&dec_path).unwrap(), plaintext);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_empty_file_roundtrip() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("empty.bin");
let enc = tmp_dir.join("empty.vx2");
let dec = tmp_dir.join("empty_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::metadata(&enc).unwrap().len(), 47);
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), b"");
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_single_byte_roundtrip() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("one.bin");
let enc = tmp_dir.join("one.vx2");
let dec = tmp_dir.join("one_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"A").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), b"A");
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_truncated_at_chunk_boundary_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("big.bin");
let enc = tmp_dir.join("big.vx2");
let truncated = tmp_dir.join("trunc.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 2 + 100).collect();
fs::write(&src, &data).unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let full = fs::read(&enc).unwrap();
let cut_len = HEADER_V3_LEN + CHUNK_SIZE + TAG_LEN;
assert!(full.len() > cut_len);
fs::write(&truncated, &full[..cut_len]).unwrap();
let dec = tmp_dir.join("dec.bin");
let result = decrypt_file(
&truncated,
&dec,
false,
password.as_bytes(),
&reporter,
None,
);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_tampered_salt_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[8] ^= 0x01;
let tampered = tmp_dir.join("tampered.vx2");
fs::write(&tampered, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_tampered_nonce_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[24] ^= 0x80;
let tampered = tmp_dir.join("tampered.vx2");
fs::write(&tampered, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_tampered_chunk_byte_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data here").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
let body_start = HEADER_V3_LEN;
assert!(bytes.len() > body_start + 4);
bytes[body_start + 4] ^= 0x10;
let tampered = tmp_dir.join("tampered.vx2");
fs::write(&tampered, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_magic_swap_v4_to_v2_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[..8].copy_from_slice(MAGIC); let swapped = tmp_dir.join("swapped.vx2");
fs::write(&swapped, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&swapped, &dec, false, password.as_bytes(), &reporter, None);
assert!(result.is_err());
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_reordered_chunks_fail() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("big.bin");
let enc = tmp_dir.join("big.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255)
.cycle()
.take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
.collect();
fs::write(&src, &data).unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
let body = &mut bytes[HEADER_V3_LEN..];
let chunk_stride = CHUNK_SIZE + TAG_LEN;
assert!(body.len() >= 2 * chunk_stride);
let (first_half, _second_half) = body.split_at_mut(2 * chunk_stride);
let (chunk0, chunk1) = first_half.split_at_mut(chunk_stride);
for i in 0..chunk_stride {
std::mem::swap(&mut chunk0[i], &mut chunk1[i]);
}
let reordered = tmp_dir.join("reordered.vx2");
fs::write(&reordered, &bytes).unwrap();
let dec = tmp_dir.join("dec.bin");
let result = decrypt_file(
&reordered,
&dec,
false,
password.as_bytes(),
&reporter,
None,
);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_extended_with_garbage_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data here").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes.extend_from_slice(&[0xABu8; 64]);
let extended = tmp_dir.join("extended.vx2");
fs::write(&extended, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&extended, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_password_length_boundary() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let reporter = TestReporter;
fs::write(&src, b"data").unwrap();
let r1 = encrypt_file(&src, &enc, false, b"1234567", &reporter, None);
assert!(matches!(r1, Err(CryptoError::PassphraseTooShort(_))));
let r2 = encrypt_file(&src, &enc, false, b"12345678", &reporter, None);
assert!(r2.is_ok());
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_chunk_minus_one_roundtrip() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("under.bin");
let enc = tmp_dir.join("under.vx2");
let dec = tmp_dir.join("under_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE - 1).collect();
fs::write(&src, &data).unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), data);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_chunk_plus_one_roundtrip() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("over.bin");
let enc = tmp_dir.join("over.vx2");
let dec = tmp_dir.join("over_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE + 1).collect();
fs::write(&src, &data).unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), data);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v3_stream_decrypt_rejects_v2_magic_via_stream() {
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let mut input = Vec::new();
input.extend_from_slice(MAGIC);
input.extend_from_slice(&[0u8; SALT_LEN]);
input.extend_from_slice(&[0u8; NONCE_LEN]);
input.extend_from_slice(&[0u8; TAG_LEN + 16]);
let mut reader = std::io::Cursor::new(input);
let mut output = Vec::new();
let result = decrypt_stream(
&mut reader,
&mut output,
password.as_bytes(),
None,
&reporter,
);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
}
#[test]
fn test_v3_rejects_oversize_file_via_constant() {
assert_eq!(MAX_V3_FILE_SIZE, (1u64 << 32) * CHUNK_SIZE as u64);
assert_eq!(MAX_V3_FILE_SIZE, 1u64 << 52);
}
#[test]
fn test_v4_constants_sanity() {
assert_eq!(HEADER_V4_LEN, HEADER_V3_LEN);
assert_eq!(MAX_V4_FILE_SIZE, MAX_V3_FILE_SIZE);
assert_eq!(MAGIC_V4, b"VAULTX04");
assert_ne!(MAGIC_V3, MAGIC_V4);
}
#[test]
fn test_v4_encrypt_produces_v4_magic() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"hello v4").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let bytes = fs::read(&enc).unwrap();
assert_eq!(&bytes[..8], MAGIC_V4);
assert_ne!(&bytes[..8], MAGIC_V3);
assert_ne!(&bytes[..8], MAGIC);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_roundtrip_multi_chunk() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("big.bin");
let enc = tmp_dir.join("big.vx2");
let dec = tmp_dir.join("big_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255)
.cycle()
.take(CHUNK_SIZE * 2 + CHUNK_SIZE / 2)
.collect();
fs::write(&src, &data).unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(&fs::read(&enc).unwrap()[..8], MAGIC_V4);
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), data);
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_tampered_salt_fails_at_aad() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[8] ^= 0x01; let tampered = tmp_dir.join("tampered.vx2");
fs::write(&tampered, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_tampered_nonce_fails_at_aad() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[24] ^= 0x80; let tampered = tmp_dir.join("tampered.vx2");
fs::write(&tampered, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&tampered, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_tampered_magic_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("plain.txt");
let enc = tmp_dir.join("plain.vx2");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"sensitive data").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let mut bytes = fs::read(&enc).unwrap();
bytes[..8].copy_from_slice(MAGIC_V3); let swapped = tmp_dir.join("swapped.vx2");
fs::write(&swapped, &bytes).unwrap();
let dec = tmp_dir.join("dec.txt");
let result = decrypt_file(&swapped, &dec, false, password.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_empty_file_roundtrip() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("empty.bin");
let enc = tmp_dir.join("empty.vx2");
let dec = tmp_dir.join("empty_dec.bin");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::metadata(&enc).unwrap().len(), 47);
decrypt_file(&enc, &dec, false, password.as_bytes(), &reporter, None).unwrap();
assert_eq!(fs::read(&dec).unwrap(), b"");
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_wrong_password_fails() {
let tmp_dir = unique_test_dir();
fs::create_dir_all(&tmp_dir).unwrap();
let src = tmp_dir.join("secret.txt");
let enc = tmp_dir.join("secret.vx2");
let dec = tmp_dir.join("dec.txt");
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let wrong = "y".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
fs::write(&src, b"top secret").unwrap();
encrypt_file(&src, &enc, false, password.as_bytes(), &reporter, None).unwrap();
let result = decrypt_file(&enc, &dec, false, wrong.as_bytes(), &reporter, None);
assert!(matches!(result, Err(CryptoError::DecryptionFailed)));
let _ = fs::remove_dir_all(tmp_dir);
}
#[test]
fn test_v4_stream_roundtrip_via_pipe() {
let password = "x".repeat(MIN_PASSWORD_LEN + 4);
let reporter = TestReporter;
let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE + 100).collect();
let mut encrypted = Vec::new();
let mut reader = std::io::Cursor::new(&data);
encrypt_stream(
&mut reader,
&mut encrypted,
password.as_bytes(),
Some(data.len() as u64),
&reporter,
)
.unwrap();
assert_eq!(&encrypted[..8], MAGIC_V4);
let mut decrypted = Vec::new();
let mut enc_reader = std::io::Cursor::new(&encrypted);
decrypt_stream(
&mut enc_reader,
&mut decrypted,
password.as_bytes(),
Some(encrypted.len() as u64),
&reporter,
)
.unwrap();
assert_eq!(decrypted, data);
}
}