1#![forbid(unsafe_code)]
2#![doc(
3 html_logo_url = "https://raw.githubusercontent.com/ArdentEmpiricist/enc_file/main/assets/logo.png"
4)]
5mod armor;
51mod crypto;
52mod file;
53mod format;
54mod hash;
55mod kdf;
56mod keymap;
57mod streaming;
58mod types;
59
60use secrecy::SecretString;
62use std::fs::File;
63use std::io::{Read, Seek, Write};
64use std::path::{Path, PathBuf};
65use zeroize::Zeroize;
66
67pub use types::{
69 AeadAlg, DEFAULT_CHUNK_SIZE, EncFileError, EncryptOptions, HashAlg, KdfAlg, KdfParams, KeyMap,
70};
71
72pub 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
82pub 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
143pub fn decrypt_bytes(input: &[u8], password: SecretString) -> Result<Vec<u8>, EncFileError> {
148 if armor::looks_armored(input) {
150 let bin = armor::dearmor_decode(input)?;
151 return decrypt_bytes(&bin, password);
152 }
153
154 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 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 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; if let Some(stream) = &header.stream {
190 streaming::validate_chunk_size_for_streaming(stream.chunk_size as usize)?;
191 }
192
193 let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
195
196 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 if body.len() as u64 != header.ct_len {
206 return Err(EncFileError::Malformed);
207 }
208
209 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
218pub 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 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 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
251pub 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 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 armor::looks_armored(peek_data) {
273 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 return decrypt_file_from_binary_data(&binary_data, &out_path, password);
281 }
282
283 input_file.rewind()?;
286
287 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 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 if header.version != format::VERSION {
300 return Err(EncFileError::UnsupportedVersion(header.version));
301 }
302
303 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; let key = kdf::derive_key_argon2id(&password, header.kdf_params, &header.salt)?;
318
319 if let Some(stream_info) = &header.stream {
320 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 let mut key_z = key;
340 crypto::zeroize_key(&mut key_z);
341
342 Ok(out_path)
343 } else {
344 let expected_body_len = header.ct_len as usize;
346
347 crypto::validate_ciphertext_length(header.ct_len)?;
349
350 let mut body = vec![0u8; expected_body_len];
351
352 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 pt.zeroize();
361
362 let mut key_z = key;
364 crypto::zeroize_key(&mut key_z);
365
366 Ok(out_path)
367 }
368}
369
370fn decrypt_file_from_binary_data(
372 binary_data: &[u8],
373 out_path: &Path,
374 password: SecretString,
375) -> Result<PathBuf, EncFileError> {
376 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 if header.version != format::VERSION {
392 return Err(EncFileError::UnsupportedVersion(header.version));
393 }
394
395 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; 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::validate_chunk_size_for_streaming(stream_info.chunk_size as usize)?;
416
417 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 let mut key_z = key;
434 crypto::zeroize_key(&mut key_z);
435
436 Ok(out_path.to_path_buf())
437 } else {
438 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 pt.zeroize();
450
451 let mut key_z = key;
453 crypto::zeroize_key(&mut key_z);
454
455 Ok(out_path.to_path_buf())
456 }
457}
458
459#[derive(Clone, Copy, Debug, Default)]
461pub struct DecryptOptions {
462 pub force: bool,
464}
465
466pub 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}