enc_file/
lib.rs

1#![forbid(unsafe_code)]
2#![doc(
3    html_logo_url = "https://raw.githubusercontent.com/ArdentEmpiricist/enc_file/main/assets/logo.png"
4)]
5//! # enc_file — password-based authenticated encryption for files.
6//!
7//! `enc_file` is a Rust library for encrypting, decrypting, and hashing files or byte arrays.
8//! It supports modern AEAD ciphers (XChaCha20-Poly1305, AES-256-GCM-SIV) with Argon2id key derivation.
9//!
10//! ## Features
11//! - **File and byte array encryption/decryption**
12//! - **Streaming encryption** for large files (constant memory usage)
13//! - **Multiple AEAD algorithms**: XChaCha20-Poly1305, AES-256-GCM-SIV
14//! - **Password-based key derivation** using Argon2id
15//! - **Key map management** for named symmetric keys
16//! - **Flexible hashing API** with support for BLAKE3, SHA2, SHA3, Blake2b, XXH3, and CRC32
17//! - **ASCII armor** for encrypted data (Base64 encoding)
18//!
19//! ## Example: Encrypt and decrypt a byte array
20//! ```no_run
21//! use enc_file::{encrypt_bytes, decrypt_bytes, EncryptOptions, AeadAlg};
22//! use secrecy::SecretString;
23//!
24//! let password = SecretString::new("mypassword".into());
25//! let opts = EncryptOptions {
26//!     alg: AeadAlg::XChaCha20Poly1305,
27//!     ..Default::default()
28//! };
29//!
30//! let ciphertext = encrypt_bytes(b"Hello, world!", password.clone(), &opts).unwrap();
31//! let plaintext = decrypt_bytes(&ciphertext, password).unwrap();
32//! assert_eq!(plaintext, b"Hello, world!");
33//! ```
34//!
35//! ## Example: Hash a file
36//! ```no_run
37//! use enc_file::{hash_file, HashAlg};
38//! use std::path::Path;
39//!
40//! let digest = hash_file(Path::new("myfile.txt"), HashAlg::Blake3).unwrap();
41//! println!("Hash: {}", enc_file::to_hex_lower(&digest));
42//! ```
43//!
44//! See function-level documentation for more details.
45//!
46//! Safety notes
47//! - The crate is not audited or reviewed! Protects data at rest. Does not defend against compromised hosts/side channels.
48
49// Module declarations
50mod armor;
51mod crypto;
52mod file;
53mod format;
54mod hash;
55mod kdf;
56mod keymap;
57mod streaming;
58mod types;
59
60// External dependencies
61use secrecy::SecretString;
62use std::fs::File;
63use std::io::{Read, Seek, Write};
64use std::path::{Path, PathBuf};
65use zeroize::Zeroize;
66
67// Re-export public types and constants
68pub use types::{
69    AeadAlg, DEFAULT_CHUNK_SIZE, EncFileError, EncryptOptions, HashAlg, KdfAlg, KdfParams, KeyMap,
70};
71
72// Re-export public functions
73// Core encryption/decryption API
74pub use armor::looks_armored;
75pub use file::default_decrypt_output_path;
76pub use hash::{
77    hash_bytes, hash_bytes_keyed_blake3, hash_file, hash_file_keyed_blake3, to_hex_lower,
78};
79pub use keymap::{load_keymap, save_keymap};
80pub use streaming::{encrypt_file_streaming, validate_chunk_size_for_streaming};
81
82// Core encryption and decryption functions
83
84/// Encrypt a byte slice using an AEAD cipher with a password-derived key.
85///
86/// This is the simplest way to encrypt in-memory data. A random salt and nonce are
87/// generated, and the result includes a self-describing header with all necessary
88/// metadata for decryption.
89///
90/// # Options via `EncryptOptions`
91/// - `alg: AeadAlg` — Cipher choice: `XChaCha20Poly1305` (default) or `Aes256GcmSiv`.
92/// - `kdf: KdfAlg` — Password KDF. Currently `Argon2id` (default).
93/// - `kdf_params: KdfParams` — Argon2id tuning:
94///   - `t_cost` (passes/iterations)
95///   - `mem_kib` (memory in KiB)
96///   - `parallelism` (lanes/threads)
97/// - `armor: bool` — Wrap output in ASCII armor (Base64) suitable for copy/paste.
98/// - `force: bool` — Overwrite existing output files (file APIs only; ignored by byte APIs).
99/// - `stream: bool` — Use streaming/chunked framing for constant memory (file APIs only).
100/// - `chunk_size: usize` — Chunk size in bytes (streaming only).
101///
102/// **Ignored fields for this function:** `force`, `stream`, `chunk_size`.
103pub fn encrypt_bytes(
104    plaintext: &[u8],
105    password: SecretString,
106    opts: &EncryptOptions,
107) -> Result<Vec<u8>, EncFileError> {
108    if opts.stream {
109        return Err(EncFileError::Invalid("use streaming APIs for stream mode"));
110    }
111
112    let salt = crypto::generate_salt()?;
113    let key = kdf::derive_key_argon2id(&password, opts.kdf_params, &salt)?;
114    let nonce = crypto::generate_nonce(opts.alg)?;
115
116    let ciphertext = crypto::aead_encrypt(opts.alg, &key, &nonce, plaintext)?;
117    let header = format::DiskHeader::new_nonstream(
118        opts.alg,
119        opts.kdf,
120        opts.kdf_params,
121        salt,
122        nonce,
123        ciphertext.len() as u64,
124    );
125
126    let mut header_bytes = Vec::new();
127    ciborium::ser::into_writer(&header, &mut header_bytes)?;
128    let mut out = Vec::new();
129    out.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
130    out.extend_from_slice(&header_bytes);
131    out.extend_from_slice(&ciphertext);
132
133    let mut key_z = key;
134    crypto::zeroize_key(&mut key_z);
135
136    if opts.armor {
137        Ok(armor::armor_encode(&out))
138    } else {
139        Ok(out)
140    }
141}
142
143/// Decrypt a byte slice that was produced by [`encrypt_bytes`].
144///
145/// The function parses the self-describing header, derives the key using the embedded
146/// Argon2id parameters, and verifies the AEAD tag before returning the plaintext.
147pub fn decrypt_bytes(input: &[u8], password: SecretString) -> Result<Vec<u8>, EncFileError> {
148    // Handle ASCII armor first (tail-recursive on the dearmored bytes)
149    if armor::looks_armored(input) {
150        let bin = armor::dearmor_decode(input)?;
151        return decrypt_bytes(&bin, password);
152    }
153
154    // Minimal header preflight
155    if input.len() < 4 {
156        return Err(EncFileError::Malformed);
157    }
158    let header_len = u32::from_le_bytes(input[0..4].try_into().unwrap()) as usize;
159    if input.len() < 4 + header_len {
160        return Err(EncFileError::Malformed);
161    }
162
163    let header_bytes = &input[4..4 + header_len];
164    let body = &input[4 + header_len..];
165
166    let header: format::DiskHeader = ciborium::de::from_reader(header_bytes)?;
167
168    // Validate header
169    if header.magic != *format::MAGIC {
170        return Err(EncFileError::Malformed);
171    }
172    if header.version != format::VERSION {
173        return Err(EncFileError::UnsupportedVersion(header.version));
174    }
175
176    // Map algorithms
177    let aead_alg = match header.aead_alg {
178        1 => AeadAlg::XChaCha20Poly1305,
179        2 => AeadAlg::Aes256GcmSiv,
180        o => return Err(EncFileError::UnsupportedAead(o)),
181    };
182    let kdf_alg = match header.kdf_alg {
183        1 => KdfAlg::Argon2id,
184        o => return Err(EncFileError::UnsupportedKdf(o)),
185    };
186    let _ = kdf_alg; // currently only Argon2id is supported
187
188    // Validate header-declared chunk size early (streaming only)
189    if let Some(stream) = &header.stream {
190        streaming::validate_chunk_size_for_streaming(stream.chunk_size as usize)?;
191    }
192
193    // Derive key
194    let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
195
196    // Streaming: parse frames into a Vec<u8>
197    if let Some(stream) = &header.stream {
198        let pt = streaming::decrypt_stream_into_vec(aead_alg, &key, stream, body)?;
199        let mut key_z = key;
200        crypto::zeroize_key(&mut key_z);
201        return Ok(pt);
202    }
203
204    // Non-streaming: body length must match `ct_len` from header
205    if body.len() as u64 != header.ct_len {
206        return Err(EncFileError::Malformed);
207    }
208    
209    // Validate ciphertext size for security
210    crypto::validate_ciphertext_length(header.ct_len)?;
211    
212    let pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, body)?;
213    let mut key_z = key;
214    crypto::zeroize_key(&mut key_z);
215    Ok(pt)
216}
217
218/// Encrypt a file on disk using the specified options.
219///
220/// For large files, consider using [`encrypt_file_streaming`] instead to maintain
221/// constant memory usage.
222pub fn encrypt_file(
223    input: &Path,
224    output: Option<&Path>,
225    password: SecretString,
226    opts: EncryptOptions,
227) -> Result<std::path::PathBuf, EncFileError> {
228    if opts.stream {
229        return encrypt_file_streaming(input, output, password, opts);
230    }
231    
232    // Validate file size before reading into memory
233    let file_metadata = std::fs::metadata(input)?;
234    crypto::validate_file_size(file_metadata.len())?;
235    
236    let mut data = Vec::new();
237    File::open(input)?.read_to_end(&mut data)?;
238    let out_bytes = encrypt_bytes(&data, password, &opts)?;
239    // Zeroize input plaintext buffer after encryption
240    data.zeroize();
241    let out_path = file::default_out_path(input, output, "enc");
242    if out_path.exists() && !opts.force {
243        return Err(EncFileError::Invalid(
244            "output exists; use --force to overwrite",
245        ));
246    }
247    file::write_all_atomic(&out_path, &out_bytes, false)?;
248    Ok(out_path)
249}
250
251/// Decrypt a file on disk that was produced by [`encrypt_file`] or [`encrypt_file_streaming`].
252pub fn decrypt_file(
253    input: &Path,
254    output: Option<&Path>,
255    password: SecretString,
256) -> Result<std::path::PathBuf, EncFileError> {
257    let out_path = file::default_out_path_for_decrypt(input, output);
258    if out_path.exists() {
259        return Err(EncFileError::Invalid(
260            "output exists; use --force to overwrite",
261        ));
262    }
263
264    let mut input_file = File::open(input)?;
265    
266    // Read a small buffer to check if the file is armored
267    let mut peek_buffer = [0u8; 1024];
268    let peek_len = input_file.read(&mut peek_buffer)?;
269    let peek_data = &peek_buffer[..peek_len];
270    
271    // If armored, we need to read the entire file to decode it
272    if armor::looks_armored(peek_data) {
273        // Reset file position and read everything for armor decoding
274        input_file.rewind()?;
275        let mut file_data = Vec::new();
276        input_file.read_to_end(&mut file_data)?;
277        let binary_data = armor::dearmor_decode(&file_data)?;
278        
279        // Process the decoded binary data in memory
280        return decrypt_file_from_binary_data(&binary_data, &out_path, password);
281    }
282    
283    // For binary files, we can be more memory-efficient
284    // Reset to beginning and parse header without reading entire file
285    input_file.rewind()?;
286    
287    // Read header length
288    let mut header_len_buf = [0u8; 4];
289    input_file.read_exact(&mut header_len_buf)?;
290    let header_len = u32::from_le_bytes(header_len_buf) as usize;
291    
292    // Read header
293    let mut header_buf = vec![0u8; header_len];
294    input_file.read_exact(&mut header_buf)?;
295    
296    let header: format::DiskHeader = ciborium::de::from_reader(&header_buf[..])?;
297    
298    // Validate format version
299    if header.version != format::VERSION {
300        return Err(EncFileError::UnsupportedVersion(header.version));
301    }
302
303    // Parse algorithms
304    let aead_alg = match header.aead_alg {
305        1 => types::AeadAlg::XChaCha20Poly1305,
306        2 => types::AeadAlg::Aes256GcmSiv,
307        o => return Err(EncFileError::UnsupportedAead(o)),
308    };
309
310    let kdf_alg = match header.kdf_alg {
311        1 => types::KdfAlg::Argon2id,
312        o => return Err(EncFileError::UnsupportedKdf(o)),
313    };
314    let _ = kdf_alg; // currently only Argon2id is supported
315
316    // Derive key
317    let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
318
319    if let Some(stream_info) = &header.stream {
320        // Streaming mode: use constant-memory streaming decryption directly from file
321        streaming::validate_chunk_size_for_streaming(stream_info.chunk_size as usize)?;
322        
323        let out_file = File::create(&out_path)?;
324        let mut buffered_out = std::io::BufWriter::with_capacity(64 * 1024, out_file);
325        
326        streaming::decrypt_stream_to_writer(
327            &mut input_file,
328            &mut buffered_out,
329            aead_alg,
330            &key,
331            stream_info,
332        )?;
333
334        buffered_out.flush()?;
335        let out_file = buffered_out.into_inner().map_err(|e| EncFileError::Io(e.into_error()))?;
336        out_file.sync_all()?;
337
338        // Zeroize derived key
339        let mut key_z = key;
340        crypto::zeroize_key(&mut key_z);
341
342        Ok(out_path)
343    } else {
344        // Non-streaming mode: read the body into memory with buffered I/O
345        let expected_body_len = header.ct_len as usize;
346        
347        // Validate ciphertext size for security
348        crypto::validate_ciphertext_length(header.ct_len)?;
349        
350        let mut body = vec![0u8; expected_body_len];
351        
352        // Use buffered reader for better performance on large non-streaming files
353        let mut buffered_input = std::io::BufReader::with_capacity(64 * 1024, input_file);
354        buffered_input.read_exact(&mut body)?;
355
356        let mut pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, &body)?;
357        file::write_all_atomic(&out_path, &pt, false)?;
358
359        // Cheap hardening: wipe decrypted plaintext buffer after writing
360        pt.zeroize();
361
362        // Zeroize derived key
363        let mut key_z = key;
364        crypto::zeroize_key(&mut key_z);
365
366        Ok(out_path)
367    }
368}
369
370/// Helper function to decrypt from binary data in memory (used for armored files).
371fn decrypt_file_from_binary_data(
372    binary_data: &[u8],
373    out_path: &Path,
374    password: SecretString,
375) -> Result<PathBuf, EncFileError> {
376    // Now we have binary data, proceed with normal decryption logic
377    if binary_data.len() < 4 {
378        return Err(EncFileError::Malformed);
379    }
380
381    let header_len = u32::from_le_bytes(binary_data[0..4].try_into().unwrap()) as usize;
382    if binary_data.len() < 4 + header_len {
383        return Err(EncFileError::Malformed);
384    }
385
386    let header_buf = &binary_data[4..4 + header_len];
387
388    let header: format::DiskHeader = ciborium::de::from_reader(header_buf)?;
389
390    // Validate format version
391    if header.version != format::VERSION {
392        return Err(EncFileError::UnsupportedVersion(header.version));
393    }
394
395    // Parse algorithms
396    let aead_alg = match header.aead_alg {
397        1 => types::AeadAlg::XChaCha20Poly1305,
398        2 => types::AeadAlg::Aes256GcmSiv,
399        o => return Err(EncFileError::UnsupportedAead(o)),
400    };
401
402    let kdf_alg = match header.kdf_alg {
403        1 => types::KdfAlg::Argon2id,
404        o => return Err(EncFileError::UnsupportedKdf(o)),
405    };
406    let _ = kdf_alg; // currently only Argon2id is supported
407
408    // Derive key
409    let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
410
411    let body = &binary_data[4 + header_len..];
412
413    if let Some(stream_info) = &header.stream {
414        // Streaming mode: use constant-memory streaming decryption
415        streaming::validate_chunk_size_for_streaming(stream_info.chunk_size as usize)?;
416
417        // For streaming with armored data, we need to create a cursor from the body data
418        use std::io::Cursor;
419        let mut reader = Cursor::new(body);
420        let mut out_file = File::create(out_path)?;
421
422        streaming::decrypt_stream_to_writer(
423            &mut reader,
424            &mut out_file,
425            aead_alg,
426            &key,
427            stream_info,
428        )?;
429
430        out_file.sync_all()?;
431
432        // Zeroize derived key
433        let mut key_z = key;
434        crypto::zeroize_key(&mut key_z);
435
436        Ok(out_path.to_path_buf())
437    } else {
438        // Non-streaming mode: decrypt the body directly
439
440        // Body length must match `ct_len` from header
441        if body.len() as u64 != header.ct_len {
442            return Err(EncFileError::Malformed);
443        }
444
445        let mut pt = crypto::aead_decrypt(aead_alg, &key, &header.nonce, body)?;
446        file::write_all_atomic(out_path, &pt, false)?;
447
448        // Cheap hardening: wipe decrypted plaintext buffer after writing
449        pt.zeroize();
450
451        // Zeroize derived key
452        let mut key_z = key;
453        crypto::zeroize_key(&mut key_z);
454
455        Ok(out_path.to_path_buf())
456    }
457}
458
459/// Decrypt options for file operations.
460#[derive(Clone, Copy, Debug, Default)]
461pub struct DecryptOptions {
462    /// Allow overwriting an existing output file.
463    pub force: bool,
464}
465
466// Helper to maintain API compatibility
467pub fn persist_tempfile_atomic(
468    tmp: tempfile::NamedTempFile,
469    out: &Path,
470    force: bool,
471) -> Result<std::path::PathBuf, EncFileError> {
472    file::persist_tempfile_atomic(tmp, out, force)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use secrecy::SecretString;
479
480    #[test]
481    fn round_trip_small_default() {
482        let pw = SecretString::new("pw".into());
483        let opts = EncryptOptions::default();
484
485        let ct = encrypt_bytes(b"abc", pw.clone(), &opts).unwrap();
486        let pt = decrypt_bytes(&ct, pw).unwrap();
487        assert_eq!(pt, b"abc");
488    }
489
490    #[test]
491    fn wrong_password_fails() {
492        let pw1 = SecretString::new("pw1".into());
493        let pw2 = SecretString::new("pw2".into());
494        let opts = EncryptOptions::default();
495
496        let ct = encrypt_bytes(b"abc", pw1, &opts).unwrap();
497        let result = decrypt_bytes(&ct, pw2);
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn armor_works() {
503        use secrecy::SecretString;
504
505        let pw = SecretString::new("pw".into());
506        let opts = EncryptOptions::default().with_armor(true);
507
508        let ct = encrypt_bytes(b"abc", pw.clone(), &opts).unwrap();
509        assert!(looks_armored(&ct));
510        let pt = decrypt_bytes(&ct, pw).unwrap();
511        assert_eq!(pt, b"abc");
512    }
513}