1use aes_gcm::{
11 Aes256Gcm, Nonce,
12 aead::{Aead, KeyInit, Payload},
13};
14use anyhow::{Context, Result, bail};
15use argon2::{
16 Algorithm, Argon2, Params, Version,
17 password_hash::{SaltString, rand_core::OsRng as PasswordHashOsRng},
18};
19use base64::prelude::*;
20use flate2::{Compression, read::DeflateDecoder, write::DeflateEncoder};
21use rand::Rng;
22use serde::{Deserialize, Serialize};
23use std::fs::{File, OpenOptions};
24use std::io::{BufReader, BufWriter, Read, Write};
25use std::path::{Path, PathBuf};
26use zeroize::{Zeroize, ZeroizeOnDrop};
27
28#[derive(Debug, thiserror::Error)]
29#[error("{0}")]
30struct AeadSourceError(aes_gcm::Error);
31
32pub const DEFAULT_CHUNK_SIZE: usize = 8 * 1024 * 1024;
34
35pub const MAX_CHUNK_SIZE: usize = 32 * 1024 * 1024;
37
38const MAX_ARCHIVE_CHUNKS: u64 = u32::MAX as u64;
39
40fn max_encryptable_plaintext_bytes(chunk_size: usize) -> u64 {
41 MAX_ARCHIVE_CHUNKS.saturating_mul(chunk_size as u64)
42}
43
44fn ensure_archive_chunk_count_fits_nonce_space(chunk_count: u64, chunk_size: usize) -> Result<()> {
45 if chunk_count > MAX_ARCHIVE_CHUNKS {
46 bail!(
47 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
48 u32::MAX,
49 max_encryptable_plaintext_bytes(chunk_size)
50 );
51 }
52 Ok(())
53}
54
55fn ensure_can_write_archive_chunk(chunk_index: u32, chunk_size: usize) -> Result<()> {
56 if chunk_index == u32::MAX {
57 bail!(
58 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
59 u32::MAX,
60 max_encryptable_plaintext_bytes(chunk_size)
61 );
62 }
63 Ok(())
64}
65
66#[cfg(not(test))]
68const ARGON2_MEMORY_KB: u32 = 65536; #[cfg(test)]
70const ARGON2_MEMORY_KB: u32 = 64;
71#[cfg(not(test))]
72const ARGON2_ITERATIONS: u32 = 3;
73#[cfg(test)]
74const ARGON2_ITERATIONS: u32 = 1;
75#[cfg(not(test))]
76const ARGON2_PARALLELISM: u32 = 4;
77#[cfg(test)]
78const ARGON2_PARALLELISM: u32 = 1;
79
80pub(crate) const SCHEMA_VERSION: u8 = 2;
82
83#[derive(Clone, Zeroize, ZeroizeOnDrop)]
85pub struct SecretKey([u8; 32]);
86
87impl SecretKey {
88 pub fn random() -> Self {
89 let mut key = [0u8; 32];
90 let mut rng = rand::rng();
91 rng.fill_bytes(&mut key);
92 Self(key)
93 }
94
95 pub fn from_bytes(bytes: [u8; 32]) -> Self {
96 Self(bytes)
97 }
98
99 pub fn as_bytes(&self) -> &[u8; 32] {
100 &self.0
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum SlotType {
108 Password,
109 Recovery,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum KdfAlgorithm {
116 Argon2id,
117 HkdfSha256,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct KeySlot {
124 pub id: u8,
125 pub slot_type: SlotType,
126 pub kdf: KdfAlgorithm,
127 pub salt: String, pub wrapped_dek: String, pub nonce: String, #[serde(skip_serializing_if = "Option::is_none")]
131 pub argon2_params: Option<Argon2Params>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct Argon2Params {
138 pub memory_kb: u32,
139 pub iterations: u32,
140 pub parallelism: u32,
141}
142
143impl Default for Argon2Params {
144 fn default() -> Self {
145 Self {
146 memory_kb: ARGON2_MEMORY_KB,
147 iterations: ARGON2_ITERATIONS,
148 parallelism: ARGON2_PARALLELISM,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct PayloadMeta {
157 pub chunk_size: usize,
158 pub chunk_count: usize,
159 pub total_compressed_size: u64,
160 pub total_plaintext_size: u64,
161 pub files: Vec<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct EncryptionConfig {
168 pub version: u8,
169 pub export_id: String, pub base_nonce: String, pub compression: String,
172 pub kdf_defaults: Argon2Params,
173 pub payload: PayloadMeta,
174 pub key_slots: Vec<KeySlot>,
175}
176
177pub(crate) fn validate_supported_payload_format(config: &EncryptionConfig) -> Result<()> {
178 if config.version != SCHEMA_VERSION {
179 bail!(
180 "Unsupported archive schema version {}; expected {}",
181 config.version,
182 SCHEMA_VERSION
183 );
184 }
185
186 if config.compression != "deflate" {
187 bail!(
188 "Unsupported archive compression '{}'. The current encrypted pages format supports only deflate.",
189 config.compression
190 );
191 }
192
193 if config.payload.chunk_size == 0 {
194 bail!("Invalid archive chunk_size 0: must be > 0");
195 }
196
197 if config.payload.chunk_size > MAX_CHUNK_SIZE {
198 bail!(
199 "Invalid archive chunk_size {}: must be <= {} bytes",
200 config.payload.chunk_size,
201 MAX_CHUNK_SIZE
202 );
203 }
204
205 if config.payload.chunk_count != config.payload.files.len() {
206 bail!(
207 "Invalid archive payload metadata: chunk_count {} does not match file list length {}",
208 config.payload.chunk_count,
209 config.payload.files.len()
210 );
211 }
212
213 if config.payload.chunk_count > u32::MAX as usize {
214 bail!(
215 "Invalid archive payload metadata: chunk_count {} exceeds maximum {}",
216 config.payload.chunk_count,
217 u32::MAX
218 );
219 }
220
221 Ok(())
222}
223
224pub struct EncryptionEngine {
228 dek: SecretKey,
229 export_id: [u8; 16],
230 base_nonce: [u8; 12],
231 chunk_size: usize,
232 key_slots: Vec<KeySlot>,
233}
234
235impl std::fmt::Debug for EncryptionEngine {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.debug_struct("EncryptionEngine")
238 .field("chunk_size", &self.chunk_size)
239 .field("key_slots", &self.key_slots.len())
240 .finish_non_exhaustive()
241 }
242}
243
244fn key_slot_id_for_len(slot_count: usize) -> Result<u8> {
245 u8::try_from(slot_count).map_err(|err| {
246 anyhow::anyhow!(
247 "maximum of 256 key slots exceeded ({} slots already allocated): {}",
248 slot_count,
249 err
250 )
251 })
252}
253
254impl Default for EncryptionEngine {
255 fn default() -> Self {
256 Self::new(DEFAULT_CHUNK_SIZE).expect("default chunk size must be valid")
257 }
258}
259
260impl EncryptionEngine {
261 pub fn new(chunk_size: usize) -> Result<Self> {
263 if chunk_size == 0 {
264 bail!("chunk_size must be > 0");
265 }
266 if chunk_size > MAX_CHUNK_SIZE {
267 bail!("chunk_size must be <= {MAX_CHUNK_SIZE} bytes");
268 }
269 let mut export_id = [0u8; 16];
270 let mut base_nonce = [0u8; 12];
271 let mut rng = rand::rng();
272 rng.fill_bytes(&mut export_id);
273 rng.fill_bytes(&mut base_nonce);
274
275 Ok(Self {
276 dek: SecretKey::random(),
277 export_id,
278 base_nonce,
279 chunk_size,
280 key_slots: Vec::new(),
281 })
282 }
283
284 pub fn add_password_slot(&mut self, password: &str) -> Result<u8> {
286 if password.is_empty() {
288 anyhow::bail!("Password cannot be empty");
289 }
290 if password.trim().is_empty() {
291 anyhow::bail!("Password cannot be whitespace-only");
292 }
293
294 let slot_id = key_slot_id_for_len(self.key_slots.len())?;
295
296 let salt = SaltString::generate(&mut PasswordHashOsRng);
298 let salt_bytes = salt.as_str().as_bytes();
299
300 let kek = derive_kek_argon2id(password, salt_bytes)?;
302
303 let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
305
306 self.key_slots.push(KeySlot {
307 id: slot_id,
308 slot_type: SlotType::Password,
309 kdf: KdfAlgorithm::Argon2id,
310 salt: BASE64_STANDARD.encode(salt_bytes),
311 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
312 nonce: BASE64_STANDARD.encode(nonce),
313 argon2_params: Some(Argon2Params::default()),
314 });
315
316 Ok(slot_id)
317 }
318
319 pub fn add_recovery_slot(&mut self, secret: &[u8]) -> Result<u8> {
321 let slot_id = key_slot_id_for_len(self.key_slots.len())?;
322
323 let mut salt = [0u8; 16];
325 let mut rng = rand::rng();
326 rng.fill_bytes(&mut salt);
327
328 let kek = derive_kek_hkdf(secret, &salt)?;
330
331 let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
333
334 self.key_slots.push(KeySlot {
335 id: slot_id,
336 slot_type: SlotType::Recovery,
337 kdf: KdfAlgorithm::HkdfSha256,
338 salt: BASE64_STANDARD.encode(salt),
339 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
340 nonce: BASE64_STANDARD.encode(nonce),
341 argon2_params: None,
342 });
343
344 Ok(slot_id)
345 }
346
347 pub fn key_slot_count(&self) -> usize {
349 self.key_slots.len()
350 }
351
352 pub fn encrypt_file<P: AsRef<Path>>(
354 &self,
355 input: P,
356 output_dir: P,
357 progress: impl Fn(u64, u64),
358 ) -> Result<EncryptionConfig> {
359 let input_path = input.as_ref();
360 let output_dir = output_dir.as_ref();
361
362 ensure_real_archive_output_directory(output_dir, "encrypted archive output directory")?;
363 let payload_dir = output_dir.join("payload");
364 ensure_real_archive_output_directory(&payload_dir, "encrypted archive payload directory")?;
365
366 let input_size = std::fs::metadata(input_path)?.len();
368 ensure_archive_chunk_count_fits_nonce_space(
369 input_size.div_ceil(self.chunk_size as u64),
370 self.chunk_size,
371 )?;
372
373 let input_file = File::open(input_path).context("Failed to open input file")?;
375 let mut reader = BufReader::new(input_file);
376
377 let mut chunk_files = Vec::new();
379 let mut chunk_index = 0u32;
380 let mut total_compressed = 0u64;
381 let mut bytes_read = 0u64;
382
383 let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
384
385 loop {
386 let mut plaintext = vec![0u8; self.chunk_size];
388 let mut total_read = 0;
389
390 while total_read < self.chunk_size {
391 match reader.read(&mut plaintext[total_read..]) {
392 Ok(0) => break, Ok(n) => {
394 total_read += n;
395 bytes_read += n as u64;
396 progress(bytes_read, input_size);
397 }
398 Err(e) => return Err(e.into()),
399 }
400 }
401
402 if total_read == 0 {
403 break; }
405 ensure_can_write_archive_chunk(chunk_index, self.chunk_size)?;
406
407 plaintext.truncate(total_read);
408
409 let mut compressed = Vec::new();
411 {
412 let mut encoder = DeflateEncoder::new(&mut compressed, Compression::default());
413 encoder.write_all(&plaintext)?;
414 encoder.finish()?;
415 }
416
417 let nonce = derive_chunk_nonce(&self.base_nonce, chunk_index);
419
420 let aad = build_chunk_aad(&self.export_id, chunk_index);
422
423 let ciphertext = cipher
425 .encrypt(
426 Nonce::from_slice(&nonce),
427 Payload {
428 msg: &compressed,
429 aad: &aad,
430 },
431 )
432 .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
433
434 let chunk_filename = format!("chunk-{:05}.bin", chunk_index);
436 let chunk_path = payload_dir.join(&chunk_filename);
437 write_encrypted_archive_file(&chunk_path, &ciphertext, "encrypted payload chunk")?;
438
439 chunk_files.push(format!("payload/{}", chunk_filename));
440 total_compressed += ciphertext.len() as u64;
441 chunk_index = chunk_index.checked_add(1).ok_or_else(|| {
442 anyhow::anyhow!(
443 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
444 u32::MAX,
445 (u32::MAX as u64) * (self.chunk_size as u64)
446 )
447 })?;
448 }
449
450 let config = EncryptionConfig {
452 version: SCHEMA_VERSION,
453 export_id: BASE64_STANDARD.encode(self.export_id),
454 base_nonce: BASE64_STANDARD.encode(self.base_nonce),
455 compression: "deflate".to_string(),
456 kdf_defaults: Argon2Params::default(),
457 payload: PayloadMeta {
458 chunk_size: self.chunk_size,
459 chunk_count: chunk_index as usize,
460 total_compressed_size: total_compressed,
461 total_plaintext_size: input_size,
462 files: chunk_files,
463 },
464 key_slots: self.key_slots.clone(),
465 };
466
467 let config_path = output_dir.join("config.json");
469 let config_payload =
470 serde_json::to_vec_pretty(&config).context("Failed to serialize encryption config")?;
471 write_encrypted_archive_file(&config_path, &config_payload, "encryption config")?;
472 sync_tree(output_dir)?;
473
474 Ok(config)
475 }
476}
477
478fn ensure_real_archive_output_directory(path: &Path, label: &str) -> Result<()> {
479 ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
480 std::fs::create_dir_all(path).with_context(|| format!("Failed to create {label}"))?;
481 ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
482
483 let metadata =
484 std::fs::symlink_metadata(path).with_context(|| format!("Failed to inspect {label}"))?;
485 let file_type = metadata.file_type();
486 if file_type.is_symlink() {
487 bail!("{label} must not be a symlink: {}", path.display());
488 }
489 if !file_type.is_dir() {
490 bail!("{label} must be a directory: {}", path.display());
491 }
492 Ok(())
493}
494
495fn ensure_existing_archive_ancestors_have_no_symlinks(path: &Path, label: &str) -> Result<()> {
496 let mut ancestors: Vec<PathBuf> = path
497 .ancestors()
498 .filter(|ancestor| !ancestor.as_os_str().is_empty())
499 .map(Path::to_path_buf)
500 .collect();
501 ancestors.reverse();
502
503 for ancestor in ancestors {
504 match std::fs::symlink_metadata(&ancestor) {
505 Ok(metadata) => {
506 let file_type = metadata.file_type();
507 if file_type.is_symlink() {
508 if is_allowed_system_symlink_ancestor(&ancestor) {
509 continue;
510 }
511 bail!("{label} must not contain symlinks: {}", ancestor.display());
512 }
513 if !file_type.is_dir() {
514 bail!(
515 "{label} parent path must be a directory: {}",
516 ancestor.display()
517 );
518 }
519 }
520 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
521 Err(err) => {
522 return Err(err)
523 .with_context(|| format!("Failed to inspect {label} {}", ancestor.display()));
524 }
525 }
526 }
527
528 Ok(())
529}
530
531#[cfg(target_os = "macos")]
532fn is_allowed_system_symlink_ancestor(path: &Path) -> bool {
533 path == Path::new("/var") || path == Path::new("/tmp")
534}
535
536#[cfg(not(target_os = "macos"))]
537fn is_allowed_system_symlink_ancestor(_path: &Path) -> bool {
538 false
539}
540
541fn write_encrypted_archive_file(path: &Path, bytes: &[u8], label: &str) -> Result<()> {
542 ensure_replaceable_archive_file(path, label)?;
543 let (mut pending, file) = PendingArchiveOutput::create(path, label)?;
544 let mut writer = BufWriter::new(file);
545 writer
546 .write_all(bytes)
547 .with_context(|| format!("Failed to write {label} {}", pending.path().display()))?;
548 writer
549 .flush()
550 .with_context(|| format!("Failed to flush {label} {}", pending.path().display()))?;
551 writer
552 .get_ref()
553 .sync_all()
554 .with_context(|| format!("Failed to sync {label} {}", pending.path().display()))?;
555 drop(writer);
556 pending.persist(path, label)
557}
558
559fn ensure_replaceable_archive_file(path: &Path, label: &str) -> Result<()> {
560 match std::fs::symlink_metadata(path) {
561 Ok(metadata) => {
562 let file_type = metadata.file_type();
563 if file_type.is_symlink() {
564 bail!(
565 "Refusing to write {label} through symlink: {}",
566 path.display()
567 );
568 }
569 if !file_type.is_file() {
570 bail!(
571 "Refusing to replace {label} at non-file path: {}",
572 path.display()
573 );
574 }
575 Ok(())
576 }
577 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
578 Err(err) => {
579 Err(err).with_context(|| format!("Failed to inspect {label} {}", path.display()))
580 }
581 }
582}
583
584struct PendingArchiveOutput {
585 path: PathBuf,
586 keep: bool,
587}
588
589impl PendingArchiveOutput {
590 fn create(final_path: &Path, label: &str) -> Result<(Self, File)> {
591 let parent = output_parent(final_path);
592 ensure_existing_archive_ancestors_have_no_symlinks(parent, label)?;
593 let file_name = final_path
594 .file_name()
595 .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
596 .to_string_lossy();
597
598 for attempt in 0..100u32 {
599 let mut random_bytes = [0u8; 8];
600 let mut rng = rand::rng();
601 rng.fill_bytes(&mut random_bytes);
602 let random = u64::from_le_bytes(random_bytes);
603 let temp_path = parent.join(format!(
604 ".{file_name}.cass-encrypt-tmp.{}.{}.{:016x}",
605 std::process::id(),
606 attempt,
607 random
608 ));
609
610 match OpenOptions::new()
611 .write(true)
612 .create_new(true)
613 .open(&temp_path)
614 {
615 Ok(file) => {
616 return Ok((
617 Self {
618 path: temp_path,
619 keep: false,
620 },
621 file,
622 ));
623 }
624 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
625 Err(err) => {
626 return Err(err).with_context(|| {
627 format!("Failed to create temporary {label} {}", temp_path.display())
628 });
629 }
630 }
631 }
632
633 bail!(
634 "Failed to create a unique temporary {label} next to {} after 100 attempts",
635 final_path.display()
636 );
637 }
638
639 fn path(&self) -> &Path {
640 &self.path
641 }
642
643 fn persist(&mut self, final_path: &Path, label: &str) -> Result<()> {
644 replace_archive_file_from_temp(&self.path, final_path, label)?;
645 self.keep = true;
646 Ok(())
647 }
648}
649
650impl Drop for PendingArchiveOutput {
651 fn drop(&mut self) {
652 if !self.keep {
653 let _ = std::fs::remove_file(&self.path);
654 }
655 }
656}
657
658fn replace_archive_file_from_temp(temp_path: &Path, final_path: &Path, label: &str) -> Result<()> {
659 replace_archive_file_from_temp_impl(temp_path, final_path, label)?;
660 sync_parent_directory(final_path)
661}
662
663#[cfg(not(windows))]
664fn replace_archive_file_from_temp_impl(
665 temp_path: &Path,
666 final_path: &Path,
667 label: &str,
668) -> Result<()> {
669 std::fs::rename(temp_path, final_path).with_context(|| {
670 format!(
671 "Failed to install {label} {} from {}",
672 final_path.display(),
673 temp_path.display()
674 )
675 })
676}
677
678#[cfg(windows)]
679fn replace_archive_file_from_temp_impl(
680 temp_path: &Path,
681 final_path: &Path,
682 label: &str,
683) -> Result<()> {
684 ensure_replaceable_archive_file(final_path, label)?;
685 if std::fs::symlink_metadata(final_path).is_err() {
686 return std::fs::rename(temp_path, final_path).with_context(|| {
687 format!(
688 "Failed to install {label} {} from {}",
689 final_path.display(),
690 temp_path.display()
691 )
692 });
693 }
694
695 let parent = output_parent(final_path);
696 let file_name = final_path
697 .file_name()
698 .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
699 .to_string_lossy();
700 let backup_path = parent.join(format!(
701 ".{file_name}.cass-encrypt-backup.{}",
702 std::process::id()
703 ));
704
705 std::fs::rename(final_path, &backup_path).with_context(|| {
706 format!(
707 "Failed to stage existing {label} {} before replacement",
708 final_path.display()
709 )
710 })?;
711
712 match std::fs::rename(temp_path, final_path) {
713 Ok(()) => {
714 let _ = std::fs::remove_file(&backup_path);
715 Ok(())
716 }
717 Err(replace_err) => match std::fs::rename(&backup_path, final_path) {
718 Ok(()) => Err(replace_err).with_context(|| {
719 format!(
720 "Failed to install {label} {}; restored previous output",
721 final_path.display()
722 )
723 }),
724 Err(restore_err) => bail!(
725 "Failed to install {label} {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
726 final_path.display(),
727 backup_path.display(),
728 restore_err,
729 temp_path.display()
730 ),
731 },
732 }
733}
734
735#[cfg(not(windows))]
736fn sync_tree(path: &Path) -> Result<()> {
737 sync_tree_inner(path)?;
745 sync_parent_directory(path)
746}
747
748#[cfg(windows)]
749fn sync_tree(_path: &Path) -> Result<()> {
750 Ok(())
755}
756
757#[cfg(not(windows))]
758fn sync_tree_inner(path: &Path) -> Result<()> {
759 let metadata = std::fs::symlink_metadata(path)?;
760 let file_type = metadata.file_type();
761 if file_type.is_symlink() {
762 return Ok(());
763 }
764 if file_type.is_file() {
765 File::open(path)?.sync_all()?;
766 return Ok(());
767 }
768 if file_type.is_dir() {
769 for entry in std::fs::read_dir(path)? {
770 sync_tree_inner(&entry?.path())?;
771 }
772 File::open(path)?.sync_all()?;
773 }
774 Ok(())
775}
776
777#[cfg(not(windows))]
783fn sync_parent_directory(path: &Path) -> Result<()> {
784 let Some(parent) = path.parent() else {
785 return Ok(());
786 };
787 File::open(parent)
788 .with_context(|| {
789 format!(
790 "failed opening parent directory {} for fsync",
791 parent.display()
792 )
793 })?
794 .sync_all()
795 .with_context(|| {
796 format!(
797 "failed syncing parent directory {} after encrypted export",
798 parent.display()
799 )
800 })
801}
802
803#[cfg(windows)]
804fn sync_parent_directory(_path: &Path) -> Result<()> {
805 Ok(())
806}
807
808pub struct DecryptionEngine {
810 dek: SecretKey,
811 config: EncryptionConfig,
812}
813
814impl DecryptionEngine {
815 pub fn unlock_with_password(config: EncryptionConfig, password: &str) -> Result<Self> {
817 validate_supported_payload_format(&config)?;
818
819 for slot in &config.key_slots {
820 if slot.slot_type != SlotType::Password {
821 continue;
822 }
823
824 let salt = BASE64_STANDARD.decode(&slot.salt)?;
825 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
826 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
827
828 let kek = derive_kek_argon2id(password, &salt)?;
829
830 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
831 if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
832 return Ok(Self {
833 dek: SecretKey::from_bytes(dek),
834 config,
835 });
836 }
837 }
838
839 bail!("Invalid password or no matching key slot")
840 }
841
842 pub fn unlock_with_recovery(config: EncryptionConfig, secret: &[u8]) -> Result<Self> {
844 validate_supported_payload_format(&config)?;
845
846 for slot in &config.key_slots {
847 if slot.slot_type != SlotType::Recovery {
848 continue;
849 }
850
851 let salt = BASE64_STANDARD.decode(&slot.salt)?;
852 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
853 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
854
855 let kek = derive_kek_hkdf(secret, &salt)?;
856
857 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
858 if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
859 return Ok(Self {
860 dek: SecretKey::from_bytes(dek),
861 config,
862 });
863 }
864 }
865
866 bail!("Invalid recovery secret or no matching key slot")
867 }
868
869 pub fn decrypt_to_file<P: AsRef<Path>>(
871 &self,
872 encrypted_dir: P,
873 output: P,
874 progress: impl Fn(usize, usize),
875 ) -> Result<()> {
876 let encrypted_dir = super::resolve_site_dir(encrypted_dir.as_ref())?;
877 let output_path = output.as_ref();
878 validate_supported_payload_format(&self.config)?;
879
880 let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
881
882 let base_nonce = BASE64_STANDARD.decode(&self.config.base_nonce)?;
883 let export_id = BASE64_STANDARD.decode(&self.config.export_id)?;
884
885 if self.config.payload.files.len() > u32::MAX as usize {
887 bail!(
888 "Invalid config: chunk count {} exceeds maximum {}",
889 self.config.payload.files.len(),
890 u32::MAX
891 );
892 }
893
894 let (mut pending_output, output_file) = PendingDecryptOutput::create(output_path)?;
895 let mut writer = BufWriter::new(output_file);
896
897 for (chunk_index, chunk_file) in self.config.payload.files.iter().enumerate() {
898 progress(chunk_index, self.config.payload.chunk_count);
899
900 if chunk_file.contains("..") || Path::new(chunk_file).is_absolute() {
902 bail!("Invalid chunk path: potential directory traversal");
903 }
904
905 let chunk_path = encrypted_dir.join(chunk_file);
906 let ciphertext = std::fs::read(&chunk_path)?;
907
908 let nonce = derive_chunk_nonce(base_nonce.as_slice().try_into()?, chunk_index as u32);
910
911 let aad = build_chunk_aad(export_id.as_slice().try_into()?, chunk_index as u32);
913
914 let compressed = cipher
916 .decrypt(
917 Nonce::from_slice(&nonce),
918 Payload {
919 msg: &ciphertext,
920 aad: &aad,
921 },
922 )
923 .map_err(|err| {
924 let context = format!(
936 "Decryption failed for chunk {} ({} bytes ciphertext): {}",
937 chunk_index,
938 ciphertext.len(),
939 err
940 );
941 anyhow::Error::new(AeadSourceError(err)).context(context)
942 })?;
943
944 let mut decoder = DeflateDecoder::new(&compressed[..]);
946 let mut plaintext = Vec::new();
947 decoder.read_to_end(&mut plaintext)?;
948
949 writer.write_all(&plaintext)?;
950 }
951
952 writer.flush()?;
953 writer
954 .get_ref()
955 .sync_all()
956 .with_context(|| format!("Failed to sync {}", pending_output.path().display()))?;
957 drop(writer);
958 pending_output.persist(output_path)?;
959
960 progress(
961 self.config.payload.chunk_count,
962 self.config.payload.chunk_count,
963 );
964
965 Ok(())
966 }
967}
968
969struct PendingDecryptOutput {
970 path: PathBuf,
971 keep: bool,
972}
973
974impl PendingDecryptOutput {
975 fn create(output_path: &Path) -> Result<(Self, File)> {
976 let parent = output_parent(output_path);
977 let file_name = output_path
978 .file_name()
979 .ok_or_else(|| anyhow::anyhow!("decryption output path must name a file"))?
980 .to_string_lossy();
981
982 for attempt in 0..100u32 {
983 let mut random_bytes = [0u8; 8];
984 let mut rng = rand::rng();
985 rng.fill_bytes(&mut random_bytes);
986 let random = u64::from_le_bytes(random_bytes);
987 let temp_path = parent.join(format!(
988 ".{file_name}.cass-decrypt-tmp.{}.{}.{:016x}",
989 std::process::id(),
990 attempt,
991 random
992 ));
993
994 let mut options = OpenOptions::new();
995 options.write(true).create_new(true);
996 #[cfg(unix)]
997 {
998 use std::os::unix::fs::OpenOptionsExt;
999 options.mode(0o600);
1000 }
1001
1002 match options.open(&temp_path) {
1003 Ok(file) => {
1004 return Ok((
1005 Self {
1006 path: temp_path,
1007 keep: false,
1008 },
1009 file,
1010 ));
1011 }
1012 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1013 Err(err) => {
1014 return Err(err).with_context(|| {
1015 format!(
1016 "Failed to create temporary decrypt output {}",
1017 temp_path.display()
1018 )
1019 });
1020 }
1021 }
1022 }
1023
1024 bail!(
1025 "Failed to create a unique temporary decrypt output next to {} after 100 attempts",
1026 output_path.display()
1027 );
1028 }
1029
1030 fn path(&self) -> &Path {
1031 &self.path
1032 }
1033
1034 fn persist(&mut self, output_path: &Path) -> Result<()> {
1035 replace_decrypt_output_from_temp(&self.path, output_path)?;
1036 self.keep = true;
1037 Ok(())
1038 }
1039}
1040
1041impl Drop for PendingDecryptOutput {
1042 fn drop(&mut self) {
1043 if !self.keep {
1044 let _ = std::fs::remove_file(&self.path);
1045 }
1046 }
1047}
1048
1049fn output_parent(output_path: &Path) -> &Path {
1050 output_path
1051 .parent()
1052 .filter(|parent| !parent.as_os_str().is_empty())
1053 .unwrap_or_else(|| Path::new("."))
1054}
1055
1056fn replace_decrypt_output_from_temp(temp_path: &Path, output_path: &Path) -> Result<()> {
1057 replace_decrypt_output_from_temp_impl(temp_path, output_path)?;
1058 sync_parent_directory(output_path)
1059}
1060
1061#[cfg(not(windows))]
1062fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1063 std::fs::rename(temp_path, output_path).with_context(|| {
1064 format!(
1065 "Failed to install decrypted output {} from {}",
1066 output_path.display(),
1067 temp_path.display()
1068 )
1069 })
1070}
1071
1072#[cfg(windows)]
1073fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1074 if std::fs::symlink_metadata(output_path).is_err() {
1075 return std::fs::rename(temp_path, output_path).with_context(|| {
1076 format!(
1077 "Failed to install decrypted output {} from {}",
1078 output_path.display(),
1079 temp_path.display()
1080 )
1081 });
1082 }
1083
1084 let parent = output_parent(output_path);
1085 let file_name = output_path
1086 .file_name()
1087 .ok_or_else(|| anyhow::anyhow!("decryption output path must name a file"))?
1088 .to_string_lossy();
1089 let backup_path = parent.join(format!(
1090 ".{file_name}.cass-decrypt-backup.{}",
1091 std::process::id()
1092 ));
1093
1094 std::fs::rename(output_path, &backup_path).with_context(|| {
1095 format!(
1096 "Failed to stage existing decrypted output {} before replacement",
1097 output_path.display()
1098 )
1099 })?;
1100
1101 match std::fs::rename(temp_path, output_path) {
1102 Ok(()) => {
1103 let _ = std::fs::remove_file(&backup_path);
1104 Ok(())
1105 }
1106 Err(replace_err) => match std::fs::rename(&backup_path, output_path) {
1107 Ok(()) => Err(replace_err).with_context(|| {
1108 format!(
1109 "Failed to install decrypted output {}; restored previous output",
1110 output_path.display()
1111 )
1112 }),
1113 Err(restore_err) => bail!(
1114 "Failed to install decrypted output {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
1115 output_path.display(),
1116 backup_path.display(),
1117 restore_err,
1118 temp_path.display()
1119 ),
1120 },
1121 }
1122}
1123
1124#[tracing::instrument(
1131 name = "derive_kek_argon2id",
1132 skip_all,
1133 fields(
1134 operation = "derive_kek_argon2id",
1135 salt_len = salt.len(),
1136 memory_kb = ARGON2_MEMORY_KB,
1137 iterations = ARGON2_ITERATIONS,
1138 parallelism = ARGON2_PARALLELISM,
1139 password_present = !password.is_empty(),
1140 )
1141)]
1142fn derive_kek_argon2id(password: &str, salt: &[u8]) -> Result<SecretKey> {
1143 let start = std::time::Instant::now();
1144 let params = Params::new(
1145 ARGON2_MEMORY_KB,
1146 ARGON2_ITERATIONS,
1147 ARGON2_PARALLELISM,
1148 Some(32),
1149 )
1150 .map_err(|e| anyhow::anyhow!("Invalid Argon2 parameters: {:?}", e))?;
1151
1152 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1153
1154 let mut kek = [0u8; 32];
1155 argon2
1156 .hash_password_into(password.as_bytes(), salt, &mut kek)
1157 .map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
1158
1159 tracing::debug!(
1160 target: "cass::pages::encrypt",
1161 operation = "derive_kek_argon2id",
1162 elapsed_ms = start.elapsed().as_millis() as u64,
1163 kek_len = kek.len(),
1164 "derive_kek_argon2id: ok"
1165 );
1166 Ok(SecretKey::from_bytes(kek))
1167}
1168
1169#[tracing::instrument(
1176 name = "derive_kek_hkdf",
1177 skip_all,
1178 fields(
1179 operation = "derive_kek_hkdf",
1180 salt_len = salt.len(),
1181 secret_len = secret.len(),
1182 info_label = "cass-pages-kek-v2",
1183 )
1184)]
1185fn derive_kek_hkdf(secret: &[u8], salt: &[u8]) -> Result<SecretKey> {
1186 let start = std::time::Instant::now();
1187 let kek = crate::encryption::hkdf_extract_expand(secret, salt, b"cass-pages-kek-v2", 32)
1188 .map_err(|e| anyhow::anyhow!("HKDF extract+expand failed for recovery secret KEK: {e}"))?;
1189 let actual_len = kek.len();
1190 let kek: [u8; 32] = kek.try_into().map_err(|_| {
1191 anyhow::anyhow!(
1192 "HKDF expansion produced invalid KEK length: expected 32, got {}",
1193 actual_len
1194 )
1195 })?;
1196 tracing::debug!(
1197 target: "cass::pages::encrypt",
1198 operation = "derive_kek_hkdf",
1199 elapsed_us = start.elapsed().as_micros() as u64,
1200 kek_len = 32,
1201 "derive_kek_hkdf: ok"
1202 );
1203 Ok(SecretKey::from_bytes(kek))
1204}
1205
1206fn wrap_key(
1208 kek: &SecretKey,
1209 dek: &[u8; 32],
1210 export_id: &[u8; 16],
1211 slot_id: u8,
1212) -> Result<(Vec<u8>, [u8; 12])> {
1213 let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1214
1215 let mut nonce = [0u8; 12];
1216 let mut rng = rand::rng();
1217 rng.fill_bytes(&mut nonce);
1218
1219 let mut aad = Vec::with_capacity(17);
1221 aad.extend_from_slice(export_id);
1222 aad.push(slot_id);
1223
1224 let wrapped = cipher
1225 .encrypt(
1226 Nonce::from_slice(&nonce),
1227 Payload {
1228 msg: dek,
1229 aad: &aad,
1230 },
1231 )
1232 .map_err(|e| anyhow::anyhow!("Key wrapping failed: {}", e))?;
1233
1234 Ok((wrapped, nonce))
1235}
1236
1237fn unwrap_key(
1239 kek: &SecretKey,
1240 wrapped: &[u8],
1241 nonce: &[u8],
1242 export_id: &[u8],
1243 slot_id: u8,
1244) -> Result<[u8; 32]> {
1245 let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1246 let nonce: &[u8; 12] = nonce
1247 .try_into()
1248 .map_err(|_| anyhow::anyhow!("invalid nonce length: expected 12, got {}", nonce.len()))?;
1249
1250 let mut aad = Vec::with_capacity(export_id.len() + 1);
1252 aad.extend_from_slice(export_id);
1253 aad.push(slot_id);
1254
1255 let dek = cipher
1256 .decrypt(
1257 Nonce::from_slice(nonce),
1258 Payload {
1259 msg: wrapped,
1260 aad: &aad,
1261 },
1262 )
1263 .map_err(|err| {
1264 let context = format!(
1276 "Key unwrapping failed for slot {} ({} bytes wrapped, {} bytes nonce, \
1277 {} bytes aad): {}",
1278 slot_id,
1279 wrapped.len(),
1280 nonce.len(),
1281 aad.len(),
1282 err
1283 );
1284 anyhow::Error::new(AeadSourceError(err)).context(context)
1285 })?;
1286
1287 let dek_len = dek.len();
1288 dek.try_into().map_err(|_| {
1289 anyhow::anyhow!(
1290 "Invalid DEK length after unwrap: expected 32, got {}",
1291 dek_len
1292 )
1293 })
1294}
1295
1296#[tracing::instrument(
1309 name = "derive_chunk_nonce",
1310 skip_all,
1311 fields(operation = "derive_chunk_nonce", chunk_index = chunk_index)
1312)]
1313fn derive_chunk_nonce(base_nonce: &[u8; 12], chunk_index: u32) -> [u8; 12] {
1314 let mut nonce = *base_nonce;
1315 nonce[8..12].copy_from_slice(&chunk_index.to_be_bytes());
1318 tracing::trace!(
1319 target: "cass::pages::encrypt",
1320 operation = "derive_chunk_nonce",
1321 chunk_index = chunk_index,
1322 "derive_chunk_nonce: ok"
1323 );
1324 nonce
1325}
1326
1327fn build_chunk_aad(export_id: &[u8; 16], chunk_index: u32) -> Vec<u8> {
1329 let mut aad = Vec::with_capacity(21);
1330 aad.extend_from_slice(export_id);
1331 aad.extend_from_slice(&chunk_index.to_be_bytes());
1332 aad.push(SCHEMA_VERSION);
1333 aad
1334}
1335
1336pub fn load_config<P: AsRef<Path>>(dir: P) -> Result<EncryptionConfig> {
1338 let archive_dir = super::resolve_site_dir(dir.as_ref())?;
1339 let config_path = archive_dir.join("config.json");
1340 let file = File::open(&config_path).context("Failed to open config.json")?;
1341 let config: EncryptionConfig = serde_json::from_reader(BufReader::new(file))?;
1342 Ok(config)
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348 use tempfile::TempDir;
1349
1350 fn assert_file_bytes(path: &Path, expected: &[u8]) {
1351 let actual = std::fs::read(path)
1352 .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
1353 assert_eq!(
1354 actual.as_slice(),
1355 expected,
1356 "unexpected bytes in {}",
1357 path.display()
1358 );
1359 }
1360
1361 fn encrypt_test_file() -> (TempDir, std::path::PathBuf, EncryptionConfig) {
1362 let temp_dir = TempDir::new().unwrap();
1363 let input_path = temp_dir.path().join("input.txt");
1364 let output_dir = temp_dir.path().join("encrypted");
1365
1366 std::fs::write(&input_path, b"payload format validation test").unwrap();
1367
1368 let mut engine = EncryptionEngine::new(1024).unwrap();
1369 engine.add_password_slot("password").unwrap();
1370 let config = engine
1371 .encrypt_file(&input_path, &output_dir, |_, _| {})
1372 .unwrap();
1373
1374 (temp_dir, output_dir, config)
1375 }
1376
1377 #[test]
1378 fn test_argon2id_key_derivation() {
1379 let password = "test-password-123";
1380 let salt = b"0123456789abcdef";
1381
1382 let kek1 = derive_kek_argon2id(password, salt).unwrap();
1383 let kek2 = derive_kek_argon2id(password, salt).unwrap();
1384
1385 assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1387
1388 let kek3 = derive_kek_argon2id("different", salt).unwrap();
1390 assert_ne!(kek1.as_bytes(), kek3.as_bytes());
1391 }
1392
1393 #[test]
1394 fn test_hkdf_key_derivation() {
1395 let secret = b"recovery-secret-bytes";
1396 let salt = [0u8; 16];
1397
1398 let kek1 = derive_kek_hkdf(secret, &salt).unwrap();
1399 let kek2 = derive_kek_hkdf(secret, &salt).unwrap();
1400
1401 assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1402 }
1403
1404 #[test]
1405 fn test_key_wrap_unwrap() {
1406 let kek = SecretKey::random();
1407 let dek = [42u8; 32];
1408 let export_id = [1u8; 16];
1409 let slot_id = 0;
1410
1411 let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).unwrap();
1412 let unwrapped = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id).unwrap();
1413
1414 assert_eq!(dek, unwrapped);
1415 }
1416
1417 #[test]
1418 fn test_key_wrap_wrong_aad_fails() {
1419 let kek = SecretKey::random();
1420 let dek = [42u8; 32];
1421 let export_id = [1u8; 16];
1422
1423 let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, 0).unwrap();
1424
1425 assert!(unwrap_key(&kek, &wrapped, &nonce, &export_id, 1).is_err());
1427
1428 let wrong_id = [2u8; 16];
1430 assert!(unwrap_key(&kek, &wrapped, &nonce, &wrong_id, 0).is_err());
1431 }
1432
1433 #[test]
1434 fn test_chunk_nonce_derivation() {
1435 let base = [0u8; 12];
1436
1437 let n0 = derive_chunk_nonce(&base, 0);
1438 let n1 = derive_chunk_nonce(&base, 1);
1439 let n2 = derive_chunk_nonce(&base, 2);
1440
1441 assert_ne!(n0, n1);
1443 assert_ne!(n1, n2);
1444 assert_ne!(n0, n2);
1445 }
1446
1447 #[test]
1448 fn test_encryption_roundtrip() {
1449 let temp_dir = TempDir::new().unwrap();
1450 let input_path = temp_dir.path().join("input.txt");
1451 let output_dir = temp_dir.path().join("encrypted");
1452 let decrypted_path = temp_dir.path().join("decrypted.txt");
1453
1454 let test_data = b"Hello, World! This is a test of the encryption system.";
1456 std::fs::write(&input_path, test_data).unwrap();
1457
1458 let mut engine = EncryptionEngine::new(1024).unwrap(); engine.add_password_slot("test-password").unwrap();
1461
1462 let config = engine
1463 .encrypt_file(&input_path, &output_dir, |_, _| {})
1464 .unwrap();
1465
1466 assert_eq!(config.version, SCHEMA_VERSION);
1467 assert!(!config.key_slots.is_empty());
1468 assert!(config.payload.chunk_count > 0);
1469
1470 let decryptor = DecryptionEngine::unlock_with_password(config, "test-password").unwrap();
1472 decryptor
1473 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1474 .unwrap();
1475
1476 assert_file_bytes(&decrypted_path, test_data);
1478 }
1479
1480 #[test]
1481 fn encrypt_file_rejects_chunk_count_beyond_nonce_space_before_writing_payload() {
1482 let temp_dir = TempDir::new().unwrap();
1483 let input_path = temp_dir.path().join("too-large.bin");
1484 let output_dir = temp_dir.path().join("encrypted");
1485
1486 let input = File::create(&input_path).unwrap();
1487 input.set_len(u64::from(u32::MAX) + 1).unwrap();
1488
1489 let mut engine = EncryptionEngine::new(1).unwrap();
1490 engine.add_password_slot("password").unwrap();
1491
1492 let err = engine
1493 .encrypt_file(&input_path, &output_dir, |_, _| {})
1494 .expect_err("archive must reject more than u32::MAX chunks");
1495 let rendered = err.to_string();
1496 assert!(
1497 rendered.contains("exceeds maximum") && rendered.contains(&u32::MAX.to_string()),
1498 "unexpected chunk-count error: {rendered}"
1499 );
1500 assert!(
1501 !output_dir.join("payload/chunk-00000.bin").exists(),
1502 "oversized sparse input must fail before writing any ciphertext chunk"
1503 );
1504 }
1505
1506 #[test]
1507 #[cfg(unix)]
1508 fn encrypt_file_rejects_symlinked_payload_directory() {
1509 use std::os::unix::fs::symlink;
1510
1511 let temp_dir = TempDir::new().unwrap();
1512 let input_path = temp_dir.path().join("input.txt");
1513 let output_dir = temp_dir.path().join("encrypted");
1514 let outside_dir = temp_dir.path().join("outside");
1515 let test_data = b"payload dir symlink regression data";
1516
1517 std::fs::write(&input_path, test_data).unwrap();
1518 std::fs::create_dir_all(&output_dir).unwrap();
1519 std::fs::create_dir_all(&outside_dir).unwrap();
1520 symlink(&outside_dir, output_dir.join("payload")).unwrap();
1521
1522 let mut engine = EncryptionEngine::new(1024).unwrap();
1523 engine.add_password_slot("test-password").unwrap();
1524 let err = engine
1525 .encrypt_file(&input_path, &output_dir, |_, _| {})
1526 .expect_err("symlinked payload directory should be rejected");
1527
1528 assert!(
1529 err.to_string().contains("must not contain symlinks"),
1530 "unexpected error: {err:#}"
1531 );
1532 assert!(
1533 !outside_dir.join("chunk-00000.bin").exists(),
1534 "encrypt_file must not write through a symlinked payload directory"
1535 );
1536 }
1537
1538 #[test]
1539 #[cfg(unix)]
1540 fn encrypt_file_rejects_symlinked_chunk_file_without_touching_target() {
1541 use std::os::unix::fs::symlink;
1542
1543 let temp_dir = TempDir::new().unwrap();
1544 let input_path = temp_dir.path().join("input.txt");
1545 let output_dir = temp_dir.path().join("encrypted");
1546 let payload_dir = output_dir.join("payload");
1547 let protected_target_path = temp_dir.path().join("protected.bin");
1548 let test_data = b"chunk file symlink regression data";
1549
1550 std::fs::write(&input_path, test_data).unwrap();
1551 std::fs::create_dir_all(&payload_dir).unwrap();
1552 std::fs::write(&protected_target_path, b"protected chunk target").unwrap();
1553 symlink(&protected_target_path, payload_dir.join("chunk-00000.bin")).unwrap();
1554
1555 let mut engine = EncryptionEngine::new(1024).unwrap();
1556 engine.add_password_slot("test-password").unwrap();
1557 let err = engine
1558 .encrypt_file(&input_path, &output_dir, |_, _| {})
1559 .expect_err("symlinked chunk file should be rejected");
1560
1561 assert!(
1562 err.to_string().contains("through symlink"),
1563 "unexpected error: {err:#}"
1564 );
1565 assert_file_bytes(&protected_target_path, b"protected chunk target");
1566 }
1567
1568 #[test]
1569 #[cfg(unix)]
1570 fn encrypt_file_rejects_symlinked_config_file_without_touching_target() {
1571 use std::os::unix::fs::symlink;
1572
1573 let temp_dir = TempDir::new().unwrap();
1574 let input_path = temp_dir.path().join("input.txt");
1575 let output_dir = temp_dir.path().join("encrypted");
1576 let protected_target_path = temp_dir.path().join("protected-config.json");
1577 let test_data = b"config symlink regression data";
1578
1579 std::fs::write(&input_path, test_data).unwrap();
1580 std::fs::create_dir_all(&output_dir).unwrap();
1581 std::fs::write(&protected_target_path, b"protected config target").unwrap();
1582 symlink(&protected_target_path, output_dir.join("config.json")).unwrap();
1583
1584 let mut engine = EncryptionEngine::new(1024).unwrap();
1585 engine.add_password_slot("test-password").unwrap();
1586 let err = engine
1587 .encrypt_file(&input_path, &output_dir, |_, _| {})
1588 .expect_err("symlinked config file should be rejected");
1589
1590 assert!(
1591 err.to_string().contains("through symlink"),
1592 "unexpected error: {err:#}"
1593 );
1594 assert_file_bytes(&protected_target_path, b"protected config target");
1595 }
1596
1597 #[test]
1598 fn test_multiple_key_slots() {
1599 let temp_dir = TempDir::new().unwrap();
1600 let input_path = temp_dir.path().join("input.txt");
1601 let output_dir = temp_dir.path().join("encrypted");
1602 let decrypted_path = temp_dir.path().join("decrypted.txt");
1603
1604 let test_data = b"Multi-slot test data";
1605 std::fs::write(&input_path, test_data).unwrap();
1606
1607 let mut engine = EncryptionEngine::new(1024).unwrap();
1609 engine.add_password_slot("password1").unwrap();
1610 engine.add_password_slot("password2").unwrap();
1611 engine.add_recovery_slot(b"recovery-secret").unwrap();
1612
1613 let config = engine
1614 .encrypt_file(&input_path, &output_dir, |_, _| {})
1615 .unwrap();
1616
1617 assert_eq!(config.key_slots.len(), 3);
1618
1619 let d1 = DecryptionEngine::unlock_with_password(config.clone(), "password1").unwrap();
1621 d1.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1622 .unwrap();
1623 assert_file_bytes(&decrypted_path, test_data);
1624
1625 let d2 = DecryptionEngine::unlock_with_password(config.clone(), "password2").unwrap();
1627 d2.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1628 .unwrap();
1629 assert_file_bytes(&decrypted_path, test_data);
1630
1631 let d3 =
1633 DecryptionEngine::unlock_with_recovery(config.clone(), b"recovery-secret").unwrap();
1634 d3.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1635 .unwrap();
1636 assert_file_bytes(&decrypted_path, test_data);
1637
1638 assert!(DecryptionEngine::unlock_with_password(config, "wrong").is_err());
1640 }
1641
1642 #[test]
1643 fn key_slot_id_for_len_rejects_overflow() {
1644 assert_eq!(key_slot_id_for_len(255).unwrap(), 255);
1645
1646 let err = key_slot_id_for_len(256).unwrap_err();
1647 assert_eq!(
1648 err.to_string(),
1649 "maximum of 256 key slots exceeded (256 slots already allocated): out of range integral type conversion attempted"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_load_config_and_decrypt_accept_bundle_root() {
1655 let temp_dir = TempDir::new().unwrap();
1656 let input_path = temp_dir.path().join("input.txt");
1657 let bundle_root = temp_dir.path().join("bundle");
1658 let site_dir = bundle_root.join("site");
1659 let decrypted_path = temp_dir.path().join("decrypted.txt");
1660
1661 let test_data = b"Bundle root decryption test data";
1662 std::fs::write(&input_path, test_data).unwrap();
1663
1664 let mut engine = EncryptionEngine::new(1024).unwrap();
1665 engine.add_password_slot("password").unwrap();
1666 engine
1667 .encrypt_file(&input_path, &site_dir, |_, _| {})
1668 .unwrap();
1669
1670 let config = load_config(&bundle_root).unwrap();
1671 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1672 decryptor
1673 .decrypt_to_file(&bundle_root, &decrypted_path, |_, _| {})
1674 .unwrap();
1675
1676 assert_file_bytes(&decrypted_path, test_data);
1677 }
1678
1679 #[test]
1680 fn test_decrypt_rejects_unsupported_payload_compression_before_unlock() {
1681 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1682 config.compression = "zstd".to_string();
1683
1684 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1685 Ok(_) => panic!("unsupported compression must fail before unlock"),
1686 Err(err) => err,
1687 };
1688
1689 let rendered = err.to_string();
1690 assert!(
1691 rendered.contains("supports only deflate") && rendered.contains("zstd"),
1692 "unexpected unsupported-compression error: {err:#}"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_decrypt_rejects_unsupported_schema_version_before_unlock() {
1698 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1699 config.version = 1;
1700
1701 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1702 Ok(_) => panic!("unsupported schema version must fail before unlock"),
1703 Err(err) => err,
1704 };
1705
1706 let rendered = err.to_string();
1707 assert!(
1708 rendered.contains("schema version") && rendered.contains("expected 2"),
1709 "unexpected unsupported-version error: {err:#}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_decrypt_rejects_mismatched_chunk_count_before_unlock() {
1715 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1716 config.payload.chunk_count += 1;
1717
1718 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1719 Ok(_) => panic!("mismatched chunk count must fail before unlock"),
1720 Err(err) => err,
1721 };
1722
1723 let rendered = err.to_string();
1724 assert!(
1725 rendered.contains("chunk_count") && rendered.contains("file list length"),
1726 "unexpected mismatched-chunk-count error: {err:#}"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_tampered_chunk_fails() {
1732 let temp_dir = TempDir::new().unwrap();
1733 let input_path = temp_dir.path().join("input.txt");
1734 let output_dir = temp_dir.path().join("encrypted");
1735 let decrypted_path = temp_dir.path().join("decrypted.txt");
1736
1737 std::fs::write(&input_path, b"Test data for tampering").unwrap();
1738
1739 let mut engine = EncryptionEngine::new(1024).unwrap();
1740 engine.add_password_slot("password").unwrap();
1741
1742 let config = engine
1743 .encrypt_file(&input_path, &output_dir, |_, _| {})
1744 .unwrap();
1745
1746 let chunk_path = output_dir.join("payload/chunk-00000.bin");
1748 let mut chunk_data = std::fs::read(&chunk_path).unwrap();
1749 chunk_data[0] ^= 0xFF; std::fs::write(&chunk_path, &chunk_data).unwrap();
1751
1752 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1754 assert!(
1755 decryptor
1756 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1757 .is_err()
1758 );
1759 }
1760
1761 #[test]
1762 fn decrypt_to_file_preserves_existing_output_when_later_chunk_fails() {
1763 let temp_dir = TempDir::new().unwrap();
1764 let input_path = temp_dir.path().join("input.txt");
1765 let output_dir = temp_dir.path().join("encrypted");
1766 let decrypted_path = temp_dir.path().join("decrypted.txt");
1767
1768 let test_data: Vec<u8> = (0..4096).map(|idx| (idx % 251) as u8).collect();
1769 std::fs::write(&input_path, &test_data).unwrap();
1770
1771 let mut engine = EncryptionEngine::new(32).unwrap();
1772 engine.add_password_slot("password").unwrap();
1773 let config = engine
1774 .encrypt_file(&input_path, &output_dir, |_, _| {})
1775 .unwrap();
1776 assert!(
1777 config.payload.chunk_count > 1,
1778 "test must produce multiple chunks to exercise partial-write failure"
1779 );
1780
1781 let existing_output = b"existing decrypted output must survive failed decrypt";
1782 std::fs::write(&decrypted_path, existing_output).unwrap();
1783
1784 let second_chunk_path = output_dir.join("payload/chunk-00001.bin");
1785 let mut second_chunk = std::fs::read(&second_chunk_path).unwrap();
1786 let last = second_chunk.len() - 1;
1787 second_chunk[last] ^= 0x55;
1788 std::fs::write(&second_chunk_path, &second_chunk).unwrap();
1789
1790 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1791 let err = decryptor
1792 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1793 .expect_err("tampered later chunk must fail");
1794 assert!(
1795 err.to_string().contains("Decryption failed for chunk 1"),
1796 "unexpected decrypt error: {err:#}"
1797 );
1798 assert_file_bytes(&decrypted_path, existing_output);
1799
1800 let leaked_temp = std::fs::read_dir(temp_dir.path())
1801 .unwrap()
1802 .filter_map(Result::ok)
1803 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1804 .any(|name| name.contains(".cass-decrypt-tmp."));
1805 assert!(
1806 !leaked_temp,
1807 "failed decrypt should not leave plaintext temp files"
1808 );
1809 }
1810
1811 #[test]
1812 #[cfg(unix)]
1813 fn decrypt_to_file_replaces_output_symlink_without_touching_target() {
1814 use std::os::unix::fs::symlink;
1815
1816 let temp_dir = TempDir::new().unwrap();
1817 let input_path = temp_dir.path().join("input.txt");
1818 let output_dir = temp_dir.path().join("encrypted");
1819 let protected_target_path = temp_dir.path().join("protected.txt");
1820 let decrypted_path = temp_dir.path().join("decrypted.txt");
1821 let test_data = b"symlink output regression data";
1822
1823 std::fs::write(&input_path, test_data).unwrap();
1824 std::fs::write(&protected_target_path, b"protected target").unwrap();
1825 symlink(&protected_target_path, &decrypted_path).unwrap();
1826
1827 let mut engine = EncryptionEngine::new(1024).unwrap();
1828 engine.add_password_slot("password").unwrap();
1829 let config = engine
1830 .encrypt_file(&input_path, &output_dir, |_, _| {})
1831 .unwrap();
1832
1833 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1834 decryptor
1835 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1836 .unwrap();
1837
1838 assert_file_bytes(&protected_target_path, b"protected target");
1839 let metadata = std::fs::symlink_metadata(&decrypted_path).unwrap();
1840 assert!(
1841 !metadata.file_type().is_symlink(),
1842 "successful decrypt should replace the output symlink itself"
1843 );
1844 assert_file_bytes(&decrypted_path, test_data);
1845 }
1846
1847 #[test]
1848 #[cfg(unix)]
1849 fn decrypt_to_file_replacement_keeps_plaintext_output_private() {
1850 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1851
1852 let temp_dir = TempDir::new().unwrap();
1853 let input_path = temp_dir.path().join("input.txt");
1854 let output_dir = temp_dir.path().join("encrypted");
1855 let decrypted_path = temp_dir.path().join("decrypted.txt");
1856 let test_data = b"private replacement mode regression data";
1857
1858 std::fs::write(&input_path, test_data).unwrap();
1859 let mut existing = OpenOptions::new()
1860 .write(true)
1861 .create_new(true)
1862 .mode(0o600)
1863 .open(&decrypted_path)
1864 .unwrap();
1865 existing.write_all(b"old private plaintext").unwrap();
1866 existing.sync_all().unwrap();
1867 drop(existing);
1868
1869 let mut engine = EncryptionEngine::new(1024).unwrap();
1870 engine.add_password_slot("password").unwrap();
1871 let config = engine
1872 .encrypt_file(&input_path, &output_dir, |_, _| {})
1873 .unwrap();
1874
1875 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1876 decryptor
1877 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1878 .unwrap();
1879
1880 assert_file_bytes(&decrypted_path, test_data);
1881 let mode = std::fs::metadata(&decrypted_path)
1882 .unwrap()
1883 .permissions()
1884 .mode()
1885 & 0o777;
1886 assert_eq!(
1887 mode, 0o600,
1888 "decrypted plaintext output should not gain group/other permissions"
1889 );
1890 }
1891
1892 #[test]
1893 fn test_encryption_engine_rejects_zero_chunk_size() {
1894 let err = EncryptionEngine::new(0).unwrap_err();
1895 assert!(err.to_string().contains("chunk_size"));
1896 }
1897
1898 #[test]
1899 fn test_encryption_engine_rejects_oversized_chunk_size() {
1900 let err = EncryptionEngine::new(MAX_CHUNK_SIZE + 1).unwrap_err();
1901 assert!(err.to_string().contains("chunk_size"));
1902 }
1903
1904 #[cfg(not(windows))]
1927 #[test]
1928 fn sync_tree_includes_parent_directory_fsync() {
1929 use std::fs;
1930 let tmp = tempfile::TempDir::new().expect("tempdir");
1931 let archive_dir = tmp.path().join("archive");
1932 fs::create_dir_all(&archive_dir).expect("create archive dir");
1933 fs::write(archive_dir.join("index.html"), b"<html></html>").unwrap();
1934 fs::write(archive_dir.join("chunk-0.bin"), [0u8; 16]).unwrap();
1935 let nested = archive_dir.join("assets");
1936 fs::create_dir_all(&nested).expect("create nested");
1937 fs::write(nested.join("style.css"), b"body{}").unwrap();
1938
1939 sync_tree(&archive_dir).expect("happy-path sync_tree must succeed");
1944
1945 let doomed_parent = tmp.path().join("doomed-parent");
1964 fs::create_dir_all(&doomed_parent).expect("create doomed parent");
1965 fs::write(doomed_parent.join("payload"), b"payload").unwrap();
1966 fs::remove_dir_all(&doomed_parent).expect("remove doomed parent");
1967 let err = sync_tree(&doomed_parent).expect_err(
1970 "sync_tree on a vanished directory must surface an I/O error; \
1971 silent Ok(()) would mean the fsync stack is a stub",
1972 );
1973 let err_str = err.to_string();
1974 assert!(
1975 err_str.contains("No such")
1976 || err_str.contains("not found")
1977 || err_str.contains("vanished")
1978 || err_str.contains("doomed"),
1979 "sync_tree error must reference the missing path or NotFound: got {err_str}"
1980 );
1981 }
1982
1983 #[test]
2002 fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2003 let kek = SecretKey::from_bytes([0u8; 32]);
2004 let dek = [0u8; 32];
2005 let export_id = [42u8; 16];
2006 let slot_id = 7u8;
2007
2008 let (mut wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).expect("wrap_key");
2010
2011 let last = wrapped.len() - 1;
2016 wrapped[last] ^= 0x55;
2017
2018 let err = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id)
2019 .expect_err("tampered ciphertext must fail unwrap");
2020 let rendered = err.to_string();
2021
2022 assert!(
2024 rendered.contains(&format!("slot {slot_id}")),
2025 "unwrap error must name the slot id; got: {rendered}"
2026 );
2027 assert!(
2029 rendered.contains(&format!("{} bytes wrapped", wrapped.len())),
2030 "unwrap error must include the wrapped-ciphertext length; got: {rendered}"
2031 );
2032 assert!(
2033 rendered.contains("12 bytes nonce"),
2034 "unwrap error must include the AES-GCM nonce length; got: {rendered}"
2035 );
2036 assert!(
2043 rendered.contains(": "),
2044 "unwrap error must include `: <source>` separator so the \
2045 aead source error survives in the chain; got: {rendered}"
2046 );
2047 let chain: Vec<String> = err.chain().map(ToString::to_string).collect();
2048 assert!(
2049 chain.len() >= 2,
2050 "unwrap error must preserve the aead source as an anyhow chain frame; \
2051 got chain: {chain:?}"
2052 );
2053 assert!(
2054 chain.iter().skip(1).any(|frame| !frame.is_empty()),
2055 "unwrap error source frame must be non-empty for debug inspection; \
2056 got chain: {chain:?}"
2057 );
2058 assert!(
2062 rendered.contains("Key unwrapping failed"),
2063 "unwrap error must keep the human-facing prefix for runbook \
2064 grep compatibility; got: {rendered}"
2065 );
2066 }
2067
2068 #[test]
2077 fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2078 let actual_kek = crate::encryption::hkdf_extract_expand(
2084 b"recovery-secret",
2085 b"salty-salty-salty-salt",
2086 b"cass-pages-kek-v2",
2087 16, )
2089 .expect("hkdf with 16-byte output must succeed");
2090 let actual_len = actual_kek.len();
2091 assert_eq!(actual_len, 16);
2092
2093 let conversion: Result<[u8; 32], Vec<u8>> = actual_kek.try_into();
2095 let raw_err = conversion.expect_err("16 != 32 must fail try_into");
2096 assert_eq!(raw_err.len(), 16);
2097
2098 let rendered = format!(
2104 "HKDF expansion produced invalid KEK length: expected 32, got {}",
2105 raw_err.len()
2106 );
2107 assert!(rendered.contains("expected 32"));
2108 assert!(rendered.contains("got 16"));
2109 }
2110}