#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/ArdentEmpiricist/enc_file/main/assets/logo.png"
)]
mod armor;
mod crypto;
mod file;
mod format;
mod hash;
mod kdf;
mod keymap;
mod streaming;
mod types;
use secrecy::SecretString;
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
pub use types::{
AeadAlg, DEFAULT_CHUNK_SIZE, EncFileError, EncryptOptions, HashAlg, KdfAlg, KdfParams, KeyMap,
};
pub use armor::looks_armored;
pub use file::default_decrypt_output_path;
pub use hash::{
hash_bytes, hash_bytes_keyed_blake3, hash_file, hash_file_keyed_blake3, to_hex_lower,
};
pub use keymap::{load_keymap, save_keymap};
pub use streaming::{encrypt_file_streaming, validate_chunk_size_for_streaming};
pub fn encrypt_bytes(
plaintext: &[u8],
password: SecretString,
opts: &EncryptOptions,
) -> Result<Vec<u8>, EncFileError> {
if opts.stream {
return Err(EncFileError::Invalid("use streaming APIs for stream mode"));
}
let salt = crypto::generate_salt()?;
let key = kdf::derive_key_argon2id(&password, opts.kdf_params, &salt)?;
let nonce = crypto::generate_nonce(opts.alg)?;
let ciphertext = crypto::aead_encrypt(opts.alg, &key, &nonce, plaintext)?;
let header = format::DiskHeader::new_nonstream(
opts.alg,
opts.kdf,
opts.kdf_params,
salt,
nonce,
ciphertext.len() as u64,
);
let mut header_bytes = Vec::new();
ciborium::ser::into_writer(&header, &mut header_bytes)?;
let mut out = Vec::new();
out.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&header_bytes);
out.extend_from_slice(&ciphertext);
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
if opts.armor {
Ok(armor::armor_encode(&out))
} else {
Ok(out)
}
}
pub fn decrypt_bytes(input: &[u8], password: SecretString) -> Result<Vec<u8>, EncFileError> {
if armor::looks_armored(input) {
let bin = armor::dearmor_decode(input)?;
return decrypt_bytes(&bin, password);
}
if input.len() < 4 {
return Err(EncFileError::Malformed);
}
let header_len = u32::from_le_bytes(input[0..4].try_into().unwrap()) as usize;
if input.len() < 4 + header_len {
return Err(EncFileError::Malformed);
}
let header_bytes = &input[4..4 + header_len];
let body = &input[4 + header_len..];
let header: format::DiskHeader = ciborium::de::from_reader(header_bytes)?;
if header.magic != *format::MAGIC {
return Err(EncFileError::Malformed);
}
if header.version != format::VERSION {
return Err(EncFileError::UnsupportedVersion(header.version));
}
let aead_alg = match header.aead_alg {
1 => AeadAlg::XChaCha20Poly1305,
2 => AeadAlg::Aes256GcmSiv,
o => return Err(EncFileError::UnsupportedAead(o)),
};
let kdf_alg = match header.kdf_alg {
1 => KdfAlg::Argon2id,
o => return Err(EncFileError::UnsupportedKdf(o)),
};
let _ = kdf_alg;
if let Some(stream) = &header.stream {
streaming::validate_chunk_size_for_streaming(stream.chunk_size as usize)?;
}
let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
if let Some(stream) = &header.stream {
let pt = streaming::decrypt_stream_into_vec(aead_alg, &key, stream, body)?;
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
return Ok(pt);
}
if body.len() as u64 != header.ct_len {
return Err(EncFileError::Malformed);
}
crypto::validate_ciphertext_length(header.ct_len)?;
let pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, body)?;
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
Ok(pt)
}
pub fn encrypt_file(
input: &Path,
output: Option<&Path>,
password: SecretString,
opts: EncryptOptions,
) -> Result<std::path::PathBuf, EncFileError> {
if opts.stream {
return encrypt_file_streaming(input, output, password, opts);
}
let file_metadata = std::fs::metadata(input)?;
crypto::validate_file_size(file_metadata.len())?;
let mut data = Vec::new();
File::open(input)?.read_to_end(&mut data)?;
let out_bytes = encrypt_bytes(&data, password, &opts)?;
data.zeroize();
let out_path = file::default_out_path(input, output, "enc");
if out_path.exists() && !opts.force {
return Err(EncFileError::Invalid(
"output exists; use --force to overwrite",
));
}
file::write_all_atomic(&out_path, &out_bytes, false)?;
Ok(out_path)
}
pub fn decrypt_file(
input: &Path,
output: Option<&Path>,
password: SecretString,
) -> Result<std::path::PathBuf, EncFileError> {
let out_path = file::default_out_path_for_decrypt(input, output);
if out_path.exists() {
return Err(EncFileError::Invalid(
"output exists; use --force to overwrite",
));
}
let mut input_file = File::open(input)?;
let mut peek_buffer = [0u8; 1024];
let peek_len = input_file.read(&mut peek_buffer)?;
let peek_data = &peek_buffer[..peek_len];
if armor::looks_armored(peek_data) {
input_file.rewind()?;
let mut file_data = Vec::new();
input_file.read_to_end(&mut file_data)?;
let binary_data = armor::dearmor_decode(&file_data)?;
return decrypt_file_from_binary_data(&binary_data, &out_path, password);
}
input_file.rewind()?;
let mut header_len_buf = [0u8; 4];
input_file.read_exact(&mut header_len_buf)?;
let header_len = u32::from_le_bytes(header_len_buf) as usize;
let mut header_buf = vec![0u8; header_len];
input_file.read_exact(&mut header_buf)?;
let header: format::DiskHeader = ciborium::de::from_reader(&header_buf[..])?;
if header.version != format::VERSION {
return Err(EncFileError::UnsupportedVersion(header.version));
}
let aead_alg = match header.aead_alg {
1 => types::AeadAlg::XChaCha20Poly1305,
2 => types::AeadAlg::Aes256GcmSiv,
o => return Err(EncFileError::UnsupportedAead(o)),
};
let kdf_alg = match header.kdf_alg {
1 => types::KdfAlg::Argon2id,
o => return Err(EncFileError::UnsupportedKdf(o)),
};
let _ = kdf_alg;
let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
if let Some(stream_info) = &header.stream {
streaming::validate_chunk_size_for_streaming(stream_info.chunk_size as usize)?;
let out_file = File::create(&out_path)?;
let mut buffered_out = std::io::BufWriter::with_capacity(64 * 1024, out_file);
streaming::decrypt_stream_to_writer(
&mut input_file,
&mut buffered_out,
aead_alg,
&key,
stream_info,
)?;
buffered_out.flush()?;
let out_file = buffered_out.into_inner().map_err(|e| EncFileError::Io(e.into_error()))?;
out_file.sync_all()?;
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
Ok(out_path)
} else {
let expected_body_len = header.ct_len as usize;
crypto::validate_ciphertext_length(header.ct_len)?;
let mut body = vec![0u8; expected_body_len];
let mut buffered_input = std::io::BufReader::with_capacity(64 * 1024, input_file);
buffered_input.read_exact(&mut body)?;
let mut pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, &body)?;
file::write_all_atomic(&out_path, &pt, false)?;
pt.zeroize();
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
Ok(out_path)
}
}
fn decrypt_file_from_binary_data(
binary_data: &[u8],
out_path: &Path,
password: SecretString,
) -> Result<PathBuf, EncFileError> {
if binary_data.len() < 4 {
return Err(EncFileError::Malformed);
}
let header_len = u32::from_le_bytes(binary_data[0..4].try_into().unwrap()) as usize;
if binary_data.len() < 4 + header_len {
return Err(EncFileError::Malformed);
}
let header_buf = &binary_data[4..4 + header_len];
let header: format::DiskHeader = ciborium::de::from_reader(header_buf)?;
if header.version != format::VERSION {
return Err(EncFileError::UnsupportedVersion(header.version));
}
let aead_alg = match header.aead_alg {
1 => types::AeadAlg::XChaCha20Poly1305,
2 => types::AeadAlg::Aes256GcmSiv,
o => return Err(EncFileError::UnsupportedAead(o)),
};
let kdf_alg = match header.kdf_alg {
1 => types::KdfAlg::Argon2id,
o => return Err(EncFileError::UnsupportedKdf(o)),
};
let _ = kdf_alg;
let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
let body = &binary_data[4 + header_len..];
if let Some(stream_info) = &header.stream {
streaming::validate_chunk_size_for_streaming(stream_info.chunk_size as usize)?;
use std::io::Cursor;
let mut reader = Cursor::new(body);
let mut out_file = File::create(out_path)?;
streaming::decrypt_stream_to_writer(
&mut reader,
&mut out_file,
aead_alg,
&key,
stream_info,
)?;
out_file.sync_all()?;
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
Ok(out_path.to_path_buf())
} else {
if body.len() as u64 != header.ct_len {
return Err(EncFileError::Malformed);
}
let mut pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, body)?;
file::write_all_atomic(out_path, &pt, false)?;
pt.zeroize();
let mut key_z = key;
crypto::zeroize_key(&mut key_z);
Ok(out_path.to_path_buf())
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct DecryptOptions {
pub force: bool,
}
pub fn persist_tempfile_atomic(
tmp: tempfile::NamedTempFile,
out: &Path,
force: bool,
) -> Result<std::path::PathBuf, EncFileError> {
file::persist_tempfile_atomic(tmp, out, force)
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::SecretString;
#[test]
fn round_trip_small_default() {
let pw = SecretString::new("pw".into());
let opts = EncryptOptions::default();
let ct = encrypt_bytes(b"abc", pw.clone(), &opts).unwrap();
let pt = decrypt_bytes(&ct, pw).unwrap();
assert_eq!(pt, b"abc");
}
#[test]
fn wrong_password_fails() {
let pw1 = SecretString::new("pw1".into());
let pw2 = SecretString::new("pw2".into());
let opts = EncryptOptions::default();
let ct = encrypt_bytes(b"abc", pw1, &opts).unwrap();
let result = decrypt_bytes(&ct, pw2);
assert!(result.is_err());
}
#[test]
fn armor_works() {
use secrecy::SecretString;
let pw = SecretString::new("pw".into());
let opts = EncryptOptions::default().with_armor(true);
let ct = encrypt_bytes(b"abc", pw.clone(), &opts).unwrap();
assert!(looks_armored(&ct));
let pt = decrypt_bytes(&ct, pw).unwrap();
assert_eq!(pt, b"abc");
}
}