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 for (index, file) in config.payload.files.iter().enumerate() {
222 if !payload_chunk_path_matches_index(file, index) {
223 return Err(invalid_payload_file_entry(index, file));
224 }
225 }
226
227 Ok(())
228}
229
230fn payload_chunk_path_matches_index(path: &str, index: usize) -> bool {
231 const PREFIX: &str = "payload/chunk-";
232 const SUFFIX: &str = ".bin";
233
234 let Some(digits) = path
235 .strip_prefix(PREFIX)
236 .and_then(|rest| rest.strip_suffix(SUFFIX))
237 else {
238 return false;
239 };
240
241 let expected_digit_count = decimal_digit_count(index).max(5);
242 if digits.len().cmp(&expected_digit_count).is_ne() {
243 return false;
244 }
245
246 let mut parsed = 0usize;
247 for byte in digits.bytes() {
248 if !byte.is_ascii_digit() {
249 return false;
250 }
251 let Some(next) = parsed
252 .checked_mul(10)
253 .and_then(|value| value.checked_add(usize::from(byte - b'0')))
254 else {
255 return false;
256 };
257 parsed = next;
258 }
259
260 parsed.cmp(&index).is_eq()
261}
262
263fn decimal_digit_count(mut value: usize) -> usize {
264 let mut count = 1;
265 while value >= 10 {
266 value /= 10;
267 count += 1;
268 }
269 count
270}
271
272fn invalid_payload_file_entry(index: usize, actual: &str) -> anyhow::Error {
273 anyhow::anyhow!(
274 "Invalid archive payload metadata: payload file entry {index} is '{actual}'; expected 'payload/chunk-{index:05}.bin'"
275 )
276}
277
278pub struct EncryptionEngine {
282 dek: SecretKey,
283 export_id: [u8; 16],
284 base_nonce: [u8; 12],
285 chunk_size: usize,
286 key_slots: Vec<KeySlot>,
287}
288
289impl std::fmt::Debug for EncryptionEngine {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 f.debug_struct("EncryptionEngine")
292 .field("chunk_size", &self.chunk_size)
293 .field("key_slots", &self.key_slots.len())
294 .finish_non_exhaustive()
295 }
296}
297
298fn key_slot_id_for_len(slot_count: usize) -> Result<u8> {
299 u8::try_from(slot_count).map_err(|err| {
300 anyhow::anyhow!(
301 "maximum of 256 key slots exceeded ({} slots already allocated): {}",
302 slot_count,
303 err
304 )
305 })
306}
307
308impl Default for EncryptionEngine {
309 fn default() -> Self {
310 Self::new(DEFAULT_CHUNK_SIZE).expect("default chunk size must be valid")
311 }
312}
313
314impl EncryptionEngine {
315 pub fn new(chunk_size: usize) -> Result<Self> {
317 if chunk_size == 0 {
318 bail!("chunk_size must be > 0");
319 }
320 if chunk_size > MAX_CHUNK_SIZE {
321 bail!("chunk_size must be <= {MAX_CHUNK_SIZE} bytes");
322 }
323 let mut export_id = [0u8; 16];
324 let mut base_nonce = [0u8; 12];
325 let mut rng = rand::rng();
326 rng.fill_bytes(&mut export_id);
327 rng.fill_bytes(&mut base_nonce);
328
329 Ok(Self {
330 dek: SecretKey::random(),
331 export_id,
332 base_nonce,
333 chunk_size,
334 key_slots: Vec::new(),
335 })
336 }
337
338 pub fn add_password_slot(&mut self, password: &str) -> Result<u8> {
340 if password.is_empty() {
342 anyhow::bail!("Password cannot be empty");
343 }
344 if password.trim().is_empty() {
345 anyhow::bail!("Password cannot be whitespace-only");
346 }
347
348 let slot_id = key_slot_id_for_len(self.key_slots.len())?;
349
350 let salt = SaltString::generate(&mut PasswordHashOsRng);
352 let salt_bytes = salt.as_str().as_bytes();
353
354 let kek = derive_kek_argon2id(password, salt_bytes)?;
356
357 let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
359
360 self.key_slots.push(KeySlot {
361 id: slot_id,
362 slot_type: SlotType::Password,
363 kdf: KdfAlgorithm::Argon2id,
364 salt: BASE64_STANDARD.encode(salt_bytes),
365 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
366 nonce: BASE64_STANDARD.encode(nonce),
367 argon2_params: Some(Argon2Params::default()),
368 });
369
370 Ok(slot_id)
371 }
372
373 pub fn add_recovery_slot(&mut self, secret: &[u8]) -> Result<u8> {
375 let slot_id = key_slot_id_for_len(self.key_slots.len())?;
376
377 let mut salt = [0u8; 16];
379 let mut rng = rand::rng();
380 rng.fill_bytes(&mut salt);
381
382 let kek = derive_kek_hkdf(secret, &salt)?;
384
385 let (wrapped_dek, nonce) = wrap_key(&kek, self.dek.as_bytes(), &self.export_id, slot_id)?;
387
388 self.key_slots.push(KeySlot {
389 id: slot_id,
390 slot_type: SlotType::Recovery,
391 kdf: KdfAlgorithm::HkdfSha256,
392 salt: BASE64_STANDARD.encode(salt),
393 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
394 nonce: BASE64_STANDARD.encode(nonce),
395 argon2_params: None,
396 });
397
398 Ok(slot_id)
399 }
400
401 pub fn key_slot_count(&self) -> usize {
403 self.key_slots.len()
404 }
405
406 pub fn encrypt_file<P: AsRef<Path>>(
408 &self,
409 input: P,
410 output_dir: P,
411 progress: impl Fn(u64, u64),
412 ) -> Result<EncryptionConfig> {
413 let input_path = input.as_ref();
414 let output_dir = output_dir.as_ref();
415
416 ensure_real_archive_output_directory(output_dir, "encrypted archive output directory")?;
417 let payload_dir = output_dir.join("payload");
418 ensure_real_archive_output_directory(&payload_dir, "encrypted archive payload directory")?;
419
420 let input_size = std::fs::metadata(input_path)?.len();
422 ensure_archive_chunk_count_fits_nonce_space(
423 input_size.div_ceil(self.chunk_size as u64),
424 self.chunk_size,
425 )?;
426
427 let input_file = File::open(input_path).context("Failed to open input file")?;
429 let mut reader = BufReader::new(input_file);
430
431 let mut chunk_files = Vec::new();
433 let mut chunk_index = 0u32;
434 let mut total_compressed = 0u64;
435 let mut bytes_read = 0u64;
436
437 let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
438
439 loop {
440 let mut plaintext = vec![0u8; self.chunk_size];
442 let mut total_read = 0;
443
444 while total_read < self.chunk_size {
445 match reader.read(&mut plaintext[total_read..]) {
446 Ok(0) => break, Ok(n) => {
448 total_read += n;
449 bytes_read += n as u64;
450 progress(bytes_read, input_size);
451 }
452 Err(e) => return Err(e.into()),
453 }
454 }
455
456 if total_read == 0 {
457 break; }
459 ensure_can_write_archive_chunk(chunk_index, self.chunk_size)?;
460
461 plaintext.truncate(total_read);
462
463 let mut compressed = Vec::new();
465 {
466 let mut encoder = DeflateEncoder::new(&mut compressed, Compression::default());
467 encoder.write_all(&plaintext)?;
468 encoder.finish()?;
469 }
470
471 let nonce = derive_chunk_nonce(&self.base_nonce, chunk_index);
473
474 let aad = build_chunk_aad(&self.export_id, chunk_index);
476
477 let ciphertext = cipher
479 .encrypt(
480 Nonce::from_slice(&nonce),
481 Payload {
482 msg: &compressed,
483 aad: &aad,
484 },
485 )
486 .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
487
488 let chunk_filename = format!("chunk-{:05}.bin", chunk_index);
490 let chunk_path = payload_dir.join(&chunk_filename);
491 write_encrypted_archive_file(&chunk_path, &ciphertext, "encrypted payload chunk")?;
492
493 chunk_files.push(format!("payload/{}", chunk_filename));
494 total_compressed += ciphertext.len() as u64;
495 chunk_index = chunk_index.checked_add(1).ok_or_else(|| {
496 anyhow::anyhow!(
497 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
498 u32::MAX,
499 (u32::MAX as u64) * (self.chunk_size as u64)
500 )
501 })?;
502 }
503
504 let config = EncryptionConfig {
506 version: SCHEMA_VERSION,
507 export_id: BASE64_STANDARD.encode(self.export_id),
508 base_nonce: BASE64_STANDARD.encode(self.base_nonce),
509 compression: "deflate".to_string(),
510 kdf_defaults: Argon2Params::default(),
511 payload: PayloadMeta {
512 chunk_size: self.chunk_size,
513 chunk_count: chunk_index as usize,
514 total_compressed_size: total_compressed,
515 total_plaintext_size: input_size,
516 files: chunk_files,
517 },
518 key_slots: self.key_slots.clone(),
519 };
520
521 let config_path = output_dir.join("config.json");
523 let config_payload =
524 serde_json::to_vec_pretty(&config).context("Failed to serialize encryption config")?;
525 write_encrypted_archive_file(&config_path, &config_payload, "encryption config")?;
526 sync_tree(output_dir)?;
527
528 Ok(config)
529 }
530}
531
532fn ensure_real_archive_output_directory(path: &Path, label: &str) -> Result<()> {
533 ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
534 std::fs::create_dir_all(path).with_context(|| format!("Failed to create {label}"))?;
535 ensure_existing_archive_ancestors_have_no_symlinks(path, label)?;
536
537 let metadata =
538 std::fs::symlink_metadata(path).with_context(|| format!("Failed to inspect {label}"))?;
539 let file_type = metadata.file_type();
540 if file_type.is_symlink() {
541 bail!("{label} must not be a symlink: {}", path.display());
542 }
543 if !file_type.is_dir() {
544 bail!("{label} must be a directory: {}", path.display());
545 }
546 Ok(())
547}
548
549fn ensure_existing_archive_ancestors_have_no_symlinks(path: &Path, label: &str) -> Result<()> {
550 let mut ancestors: Vec<PathBuf> = path
551 .ancestors()
552 .filter(|ancestor| !ancestor.as_os_str().is_empty())
553 .map(Path::to_path_buf)
554 .collect();
555 ancestors.reverse();
556
557 for ancestor in ancestors {
558 match std::fs::symlink_metadata(&ancestor) {
559 Ok(metadata) => {
560 let file_type = metadata.file_type();
561 if file_type.is_symlink() {
562 if is_allowed_system_symlink_ancestor(&ancestor) {
563 continue;
564 }
565 bail!("{label} must not contain symlinks: {}", ancestor.display());
566 }
567 if !file_type.is_dir() {
568 bail!(
569 "{label} parent path must be a directory: {}",
570 ancestor.display()
571 );
572 }
573 }
574 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
575 Err(err) => {
576 return Err(err)
577 .with_context(|| format!("Failed to inspect {label} {}", ancestor.display()));
578 }
579 }
580 }
581
582 Ok(())
583}
584
585#[cfg(target_os = "macos")]
586fn is_allowed_system_symlink_ancestor(path: &Path) -> bool {
587 path == Path::new("/var") || path == Path::new("/tmp")
588}
589
590#[cfg(not(target_os = "macos"))]
591fn is_allowed_system_symlink_ancestor(_path: &Path) -> bool {
592 false
593}
594
595fn write_encrypted_archive_file(path: &Path, bytes: &[u8], label: &str) -> Result<()> {
596 ensure_replaceable_archive_file(path, label)?;
597 let (mut pending, file) = PendingArchiveOutput::create(path, label)?;
598 let mut writer = BufWriter::new(file);
599 writer
600 .write_all(bytes)
601 .with_context(|| format!("Failed to write {label} {}", pending.path().display()))?;
602 writer
603 .flush()
604 .with_context(|| format!("Failed to flush {label} {}", pending.path().display()))?;
605 writer
606 .get_ref()
607 .sync_all()
608 .with_context(|| format!("Failed to sync {label} {}", pending.path().display()))?;
609 drop(writer);
610 pending.persist(path, label)
611}
612
613fn ensure_replaceable_archive_file(path: &Path, label: &str) -> Result<()> {
614 match std::fs::symlink_metadata(path) {
615 Ok(metadata) => {
616 let file_type = metadata.file_type();
617 if file_type.is_symlink() {
618 bail!(
619 "Refusing to write {label} through symlink: {}",
620 path.display()
621 );
622 }
623 if !file_type.is_file() {
624 bail!(
625 "Refusing to replace {label} at non-file path: {}",
626 path.display()
627 );
628 }
629 Ok(())
630 }
631 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
632 Err(err) => {
633 Err(err).with_context(|| format!("Failed to inspect {label} {}", path.display()))
634 }
635 }
636}
637
638#[cfg(any(windows, test))]
639fn replacement_path_entry_exists(path: &Path, label: &str) -> Result<bool> {
640 match std::fs::symlink_metadata(path) {
641 Ok(_) => Ok(true),
642 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
643 Err(err) => {
644 Err(err).with_context(|| format!("Failed to inspect {label} {}", path.display()))
645 }
646 }
647}
648
649#[cfg(any(windows, test))]
650fn unique_replacement_backup_path(path: &Path, purpose: &str, label: &str) -> Result<PathBuf> {
651 static BACKUP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
652
653 let parent = output_parent(path);
654 let file_name = path
655 .file_name()
656 .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
657 .to_string_lossy();
658 let counter = BACKUP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
659 let timestamp = std::time::SystemTime::now()
660 .duration_since(std::time::UNIX_EPOCH)
661 .unwrap_or_else(|_| std::time::Duration::from_secs(0))
662 .as_nanos();
663 let candidate = parent.join(format!(
664 ".{file_name}.{purpose}.{}.{}.{timestamp:x}",
665 std::process::id(),
666 counter
667 ));
668
669 match std::fs::symlink_metadata(&candidate) {
670 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(candidate),
671 Ok(_) => {
672 bail!(
673 "Replacement backup path for {label} already exists: {}",
674 candidate.display()
675 );
676 }
677 Err(err) => Err(err).with_context(|| {
678 format!(
679 "Failed to inspect replacement backup path {}",
680 candidate.display()
681 )
682 }),
683 }
684}
685
686struct PendingArchiveOutput {
687 path: PathBuf,
688 keep: bool,
689}
690
691impl PendingArchiveOutput {
692 fn create(final_path: &Path, label: &str) -> Result<(Self, File)> {
693 let parent = output_parent(final_path);
694 ensure_existing_archive_ancestors_have_no_symlinks(parent, label)?;
695 let file_name = final_path
696 .file_name()
697 .ok_or_else(|| anyhow::anyhow!("{label} path must name a file"))?
698 .to_string_lossy();
699
700 for attempt in 0..100u32 {
701 let mut random_bytes = [0u8; 8];
702 let mut rng = rand::rng();
703 rng.fill_bytes(&mut random_bytes);
704 let random = u64::from_le_bytes(random_bytes);
705 let temp_path = parent.join(format!(
706 ".{file_name}.cass-encrypt-tmp.{}.{}.{:016x}",
707 std::process::id(),
708 attempt,
709 random
710 ));
711
712 match OpenOptions::new()
713 .write(true)
714 .create_new(true)
715 .open(&temp_path)
716 {
717 Ok(file) => {
718 return Ok((
719 Self {
720 path: temp_path,
721 keep: false,
722 },
723 file,
724 ));
725 }
726 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
727 Err(err) => {
728 return Err(err).with_context(|| {
729 format!("Failed to create temporary {label} {}", temp_path.display())
730 });
731 }
732 }
733 }
734
735 bail!(
736 "Failed to create a unique temporary {label} next to {} after 100 attempts",
737 final_path.display()
738 );
739 }
740
741 fn path(&self) -> &Path {
742 &self.path
743 }
744
745 fn persist(&mut self, final_path: &Path, label: &str) -> Result<()> {
746 replace_archive_file_from_temp(&self.path, final_path, label)?;
747 self.keep = true;
748 Ok(())
749 }
750}
751
752impl Drop for PendingArchiveOutput {
753 fn drop(&mut self) {
754 if !self.keep {
755 let _ = std::fs::remove_file(&self.path);
756 }
757 }
758}
759
760fn replace_archive_file_from_temp(temp_path: &Path, final_path: &Path, label: &str) -> Result<()> {
761 replace_archive_file_from_temp_impl(temp_path, final_path, label)?;
762 sync_parent_directory(final_path)
763}
764
765#[cfg(not(windows))]
766fn replace_archive_file_from_temp_impl(
767 temp_path: &Path,
768 final_path: &Path,
769 label: &str,
770) -> Result<()> {
771 std::fs::rename(temp_path, final_path).with_context(|| {
772 format!(
773 "Failed to install {label} {} from {}",
774 final_path.display(),
775 temp_path.display()
776 )
777 })
778}
779
780#[cfg(windows)]
781fn replace_archive_file_from_temp_impl(
782 temp_path: &Path,
783 final_path: &Path,
784 label: &str,
785) -> Result<()> {
786 ensure_replaceable_archive_file(final_path, label)?;
787 if !replacement_path_entry_exists(final_path, label)? {
788 return std::fs::rename(temp_path, final_path).with_context(|| {
789 format!(
790 "Failed to install {label} {} from {}",
791 final_path.display(),
792 temp_path.display()
793 )
794 });
795 }
796
797 replace_archive_file_from_temp_via_backup(temp_path, final_path, label)
798}
799
800#[cfg(any(windows, test))]
801fn replace_archive_file_from_temp_via_backup(
802 temp_path: &Path,
803 final_path: &Path,
804 label: &str,
805) -> Result<()> {
806 let backup_path = unique_replacement_backup_path(final_path, "cass-encrypt-backup", label)?;
807
808 std::fs::rename(final_path, &backup_path).with_context(|| {
809 format!(
810 "Failed to stage existing {label} {} before replacement",
811 final_path.display()
812 )
813 })?;
814
815 match std::fs::rename(temp_path, final_path) {
816 Ok(()) => {
817 let _ = std::fs::remove_file(&backup_path);
818 Ok(())
819 }
820 Err(replace_err) => match std::fs::rename(&backup_path, final_path) {
821 Ok(()) => Err(replace_err).with_context(|| {
822 format!(
823 "Failed to install {label} {}; restored previous output",
824 final_path.display()
825 )
826 }),
827 Err(restore_err) => bail!(
828 "Failed to install {label} {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
829 final_path.display(),
830 backup_path.display(),
831 restore_err,
832 temp_path.display()
833 ),
834 },
835 }
836}
837
838#[cfg(not(windows))]
839fn sync_tree(path: &Path) -> Result<()> {
840 sync_tree_inner(path)?;
848 sync_parent_directory(path)
849}
850
851#[cfg(windows)]
852fn sync_tree(_path: &Path) -> Result<()> {
853 Ok(())
858}
859
860#[cfg(not(windows))]
861fn sync_tree_inner(path: &Path) -> Result<()> {
862 let metadata = std::fs::symlink_metadata(path)?;
863 let file_type = metadata.file_type();
864 if file_type.is_symlink() {
865 return Ok(());
866 }
867 if file_type.is_file() {
868 File::open(path)?.sync_all()?;
869 return Ok(());
870 }
871 if file_type.is_dir() {
872 for entry in std::fs::read_dir(path)? {
873 sync_tree_inner(&entry?.path())?;
874 }
875 File::open(path)?.sync_all()?;
876 }
877 Ok(())
878}
879
880#[cfg(not(windows))]
886fn sync_parent_directory(path: &Path) -> Result<()> {
887 let Some(parent) = path.parent() else {
888 return Ok(());
889 };
890 File::open(parent)
891 .with_context(|| {
892 format!(
893 "failed opening parent directory {} for fsync",
894 parent.display()
895 )
896 })?
897 .sync_all()
898 .with_context(|| {
899 format!(
900 "failed syncing parent directory {} after encrypted export",
901 parent.display()
902 )
903 })
904}
905
906#[cfg(windows)]
907fn sync_parent_directory(_path: &Path) -> Result<()> {
908 Ok(())
909}
910
911pub struct DecryptionEngine {
913 dek: SecretKey,
914 config: EncryptionConfig,
915}
916
917impl DecryptionEngine {
918 pub fn unlock_with_password(config: EncryptionConfig, password: &str) -> Result<Self> {
920 validate_supported_payload_format(&config)?;
921
922 for slot in &config.key_slots {
923 if slot.slot_type != SlotType::Password {
924 continue;
925 }
926
927 let salt = BASE64_STANDARD.decode(&slot.salt)?;
928 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
929 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
930
931 let kek = derive_kek_argon2id(password, &salt)?;
932
933 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
934 if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
935 return Ok(Self {
936 dek: SecretKey::from_bytes(dek),
937 config,
938 });
939 }
940 }
941
942 bail!("Invalid password or no matching key slot")
943 }
944
945 pub fn unlock_with_recovery(config: EncryptionConfig, secret: &[u8]) -> Result<Self> {
947 validate_supported_payload_format(&config)?;
948
949 for slot in &config.key_slots {
950 if slot.slot_type != SlotType::Recovery {
951 continue;
952 }
953
954 let salt = BASE64_STANDARD.decode(&slot.salt)?;
955 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
956 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
957
958 let kek = derive_kek_hkdf(secret, &salt)?;
959
960 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
961 if let Ok(dek) = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id) {
962 return Ok(Self {
963 dek: SecretKey::from_bytes(dek),
964 config,
965 });
966 }
967 }
968
969 bail!("Invalid recovery secret or no matching key slot")
970 }
971
972 pub fn decrypt_to_file<P: AsRef<Path>>(
974 &self,
975 encrypted_dir: P,
976 output: P,
977 progress: impl Fn(usize, usize),
978 ) -> Result<()> {
979 let encrypted_dir = super::resolve_site_dir(encrypted_dir.as_ref())?;
980 let output_path = output.as_ref();
981 validate_supported_payload_format(&self.config)?;
982
983 let cipher = Aes256Gcm::new_from_slice(self.dek.as_bytes()).expect("Invalid key length");
984
985 let base_nonce = BASE64_STANDARD.decode(&self.config.base_nonce)?;
986 let export_id = BASE64_STANDARD.decode(&self.config.export_id)?;
987
988 if self.config.payload.files.len() > u32::MAX as usize {
990 bail!(
991 "Invalid config: chunk count {} exceeds maximum {}",
992 self.config.payload.files.len(),
993 u32::MAX
994 );
995 }
996
997 let (mut pending_output, output_file) = PendingDecryptOutput::create(output_path)?;
998 let mut writer = BufWriter::new(output_file);
999
1000 for (chunk_index, chunk_file) in self.config.payload.files.iter().enumerate() {
1001 progress(chunk_index, self.config.payload.chunk_count);
1002
1003 if chunk_file.contains("..") || Path::new(chunk_file).is_absolute() {
1005 bail!("Invalid chunk path: potential directory traversal");
1006 }
1007
1008 let chunk_path = encrypted_dir.join(chunk_file);
1009 let ciphertext = std::fs::read(&chunk_path)?;
1010
1011 let nonce = derive_chunk_nonce(base_nonce.as_slice().try_into()?, chunk_index as u32);
1013
1014 let aad = build_chunk_aad(export_id.as_slice().try_into()?, chunk_index as u32);
1016
1017 let compressed = cipher
1019 .decrypt(
1020 Nonce::from_slice(&nonce),
1021 Payload {
1022 msg: &ciphertext,
1023 aad: &aad,
1024 },
1025 )
1026 .map_err(|err| {
1027 let context = format!(
1039 "Decryption failed for chunk {} ({} bytes ciphertext): {}",
1040 chunk_index,
1041 ciphertext.len(),
1042 err
1043 );
1044 anyhow::Error::new(AeadSourceError(err)).context(context)
1045 })?;
1046
1047 let mut decoder = DeflateDecoder::new(&compressed[..]);
1049 let mut plaintext = Vec::new();
1050 decoder.read_to_end(&mut plaintext)?;
1051
1052 writer.write_all(&plaintext)?;
1053 }
1054
1055 writer.flush()?;
1056 writer
1057 .get_ref()
1058 .sync_all()
1059 .with_context(|| format!("Failed to sync {}", pending_output.path().display()))?;
1060 drop(writer);
1061 pending_output.persist(output_path)?;
1062
1063 progress(
1064 self.config.payload.chunk_count,
1065 self.config.payload.chunk_count,
1066 );
1067
1068 Ok(())
1069 }
1070}
1071
1072struct PendingDecryptOutput {
1073 path: PathBuf,
1074 keep: bool,
1075}
1076
1077impl PendingDecryptOutput {
1078 fn create(output_path: &Path) -> Result<(Self, File)> {
1079 let parent = output_parent(output_path);
1080 let file_name = output_path
1081 .file_name()
1082 .ok_or_else(|| anyhow::anyhow!("decryption output path must name a file"))?
1083 .to_string_lossy();
1084
1085 for attempt in 0..100u32 {
1086 let mut random_bytes = [0u8; 8];
1087 let mut rng = rand::rng();
1088 rng.fill_bytes(&mut random_bytes);
1089 let random = u64::from_le_bytes(random_bytes);
1090 let temp_path = parent.join(format!(
1091 ".{file_name}.cass-decrypt-tmp.{}.{}.{:016x}",
1092 std::process::id(),
1093 attempt,
1094 random
1095 ));
1096
1097 let mut options = OpenOptions::new();
1098 options.write(true).create_new(true);
1099 #[cfg(unix)]
1100 {
1101 use std::os::unix::fs::OpenOptionsExt;
1102 options.mode(0o600);
1103 }
1104
1105 match options.open(&temp_path) {
1106 Ok(file) => {
1107 return Ok((
1108 Self {
1109 path: temp_path,
1110 keep: false,
1111 },
1112 file,
1113 ));
1114 }
1115 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1116 Err(err) => {
1117 return Err(err).with_context(|| {
1118 format!(
1119 "Failed to create temporary decrypt output {}",
1120 temp_path.display()
1121 )
1122 });
1123 }
1124 }
1125 }
1126
1127 bail!(
1128 "Failed to create a unique temporary decrypt output next to {} after 100 attempts",
1129 output_path.display()
1130 );
1131 }
1132
1133 fn path(&self) -> &Path {
1134 &self.path
1135 }
1136
1137 fn persist(&mut self, output_path: &Path) -> Result<()> {
1138 replace_decrypt_output_from_temp(&self.path, output_path)?;
1139 self.keep = true;
1140 Ok(())
1141 }
1142}
1143
1144impl Drop for PendingDecryptOutput {
1145 fn drop(&mut self) {
1146 if !self.keep {
1147 let _ = std::fs::remove_file(&self.path);
1148 }
1149 }
1150}
1151
1152fn output_parent(output_path: &Path) -> &Path {
1153 output_path
1154 .parent()
1155 .filter(|parent| !parent.as_os_str().is_empty())
1156 .unwrap_or_else(|| Path::new("."))
1157}
1158
1159fn replace_decrypt_output_from_temp(temp_path: &Path, output_path: &Path) -> Result<()> {
1160 replace_decrypt_output_from_temp_impl(temp_path, output_path)?;
1161 sync_parent_directory(output_path)
1162}
1163
1164#[cfg(not(windows))]
1165fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1166 std::fs::rename(temp_path, output_path).with_context(|| {
1167 format!(
1168 "Failed to install decrypted output {} from {}",
1169 output_path.display(),
1170 temp_path.display()
1171 )
1172 })
1173}
1174
1175#[cfg(windows)]
1176fn replace_decrypt_output_from_temp_impl(temp_path: &Path, output_path: &Path) -> Result<()> {
1177 if !replacement_path_entry_exists(output_path, "decrypted output")? {
1178 return std::fs::rename(temp_path, output_path).with_context(|| {
1179 format!(
1180 "Failed to install decrypted output {} from {}",
1181 output_path.display(),
1182 temp_path.display()
1183 )
1184 });
1185 }
1186
1187 replace_decrypt_output_from_temp_via_backup(temp_path, output_path)
1188}
1189
1190#[cfg(any(windows, test))]
1191fn replace_decrypt_output_from_temp_via_backup(temp_path: &Path, output_path: &Path) -> Result<()> {
1192 let backup_path =
1193 unique_replacement_backup_path(output_path, "cass-decrypt-backup", "decrypted output")?;
1194
1195 std::fs::rename(output_path, &backup_path).with_context(|| {
1196 format!(
1197 "Failed to stage existing decrypted output {} before replacement",
1198 output_path.display()
1199 )
1200 })?;
1201
1202 match std::fs::rename(temp_path, output_path) {
1203 Ok(()) => {
1204 let _ = std::fs::remove_file(&backup_path);
1205 Ok(())
1206 }
1207 Err(replace_err) => match std::fs::rename(&backup_path, output_path) {
1208 Ok(()) => Err(replace_err).with_context(|| {
1209 format!(
1210 "Failed to install decrypted output {}; restored previous output",
1211 output_path.display()
1212 )
1213 }),
1214 Err(restore_err) => bail!(
1215 "Failed to install decrypted output {}; also failed to restore previous output from {}: {}; temporary output retained at {}",
1216 output_path.display(),
1217 backup_path.display(),
1218 restore_err,
1219 temp_path.display()
1220 ),
1221 },
1222 }
1223}
1224
1225#[tracing::instrument(
1232 name = "derive_kek_argon2id",
1233 skip_all,
1234 fields(
1235 operation = "derive_kek_argon2id",
1236 salt_len = salt.len(),
1237 memory_kb = ARGON2_MEMORY_KB,
1238 iterations = ARGON2_ITERATIONS,
1239 parallelism = ARGON2_PARALLELISM,
1240 password_present = !password.is_empty(),
1241 )
1242)]
1243fn derive_kek_argon2id(password: &str, salt: &[u8]) -> Result<SecretKey> {
1244 let start = std::time::Instant::now();
1245 let params = Params::new(
1246 ARGON2_MEMORY_KB,
1247 ARGON2_ITERATIONS,
1248 ARGON2_PARALLELISM,
1249 Some(32),
1250 )
1251 .map_err(|e| anyhow::anyhow!("Invalid Argon2 parameters: {:?}", e))?;
1252
1253 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
1254
1255 let mut kek = [0u8; 32];
1256 argon2
1257 .hash_password_into(password.as_bytes(), salt, &mut kek)
1258 .map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
1259
1260 tracing::debug!(
1261 target: "cass::pages::encrypt",
1262 operation = "derive_kek_argon2id",
1263 elapsed_ms = start.elapsed().as_millis() as u64,
1264 kek_len = kek.len(),
1265 "derive_kek_argon2id: ok"
1266 );
1267 Ok(SecretKey::from_bytes(kek))
1268}
1269
1270#[tracing::instrument(
1277 name = "derive_kek_hkdf",
1278 skip_all,
1279 fields(
1280 operation = "derive_kek_hkdf",
1281 salt_len = salt.len(),
1282 secret_len = secret.len(),
1283 info_label = "cass-pages-kek-v2",
1284 )
1285)]
1286fn derive_kek_hkdf(secret: &[u8], salt: &[u8]) -> Result<SecretKey> {
1287 let start = std::time::Instant::now();
1288 let kek = crate::encryption::hkdf_extract_expand(secret, salt, b"cass-pages-kek-v2", 32)
1289 .map_err(|e| anyhow::anyhow!("HKDF extract+expand failed for recovery secret KEK: {e}"))?;
1290 let actual_len = kek.len();
1291 let kek: [u8; 32] = kek.try_into().map_err(|_| {
1292 anyhow::anyhow!(
1293 "HKDF expansion produced invalid KEK length: expected 32, got {}",
1294 actual_len
1295 )
1296 })?;
1297 tracing::debug!(
1298 target: "cass::pages::encrypt",
1299 operation = "derive_kek_hkdf",
1300 elapsed_us = start.elapsed().as_micros() as u64,
1301 kek_len = 32,
1302 "derive_kek_hkdf: ok"
1303 );
1304 Ok(SecretKey::from_bytes(kek))
1305}
1306
1307fn wrap_key(
1309 kek: &SecretKey,
1310 dek: &[u8; 32],
1311 export_id: &[u8; 16],
1312 slot_id: u8,
1313) -> Result<(Vec<u8>, [u8; 12])> {
1314 let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1315
1316 let mut nonce = [0u8; 12];
1317 let mut rng = rand::rng();
1318 rng.fill_bytes(&mut nonce);
1319
1320 let mut aad = Vec::with_capacity(17);
1322 aad.extend_from_slice(export_id);
1323 aad.push(slot_id);
1324
1325 let wrapped = cipher
1326 .encrypt(
1327 Nonce::from_slice(&nonce),
1328 Payload {
1329 msg: dek,
1330 aad: &aad,
1331 },
1332 )
1333 .map_err(|e| anyhow::anyhow!("Key wrapping failed: {}", e))?;
1334
1335 Ok((wrapped, nonce))
1336}
1337
1338fn unwrap_key(
1340 kek: &SecretKey,
1341 wrapped: &[u8],
1342 nonce: &[u8],
1343 export_id: &[u8],
1344 slot_id: u8,
1345) -> Result<[u8; 32]> {
1346 let cipher = Aes256Gcm::new_from_slice(kek.as_bytes()).expect("Invalid key length");
1347 let nonce: &[u8; 12] = nonce
1348 .try_into()
1349 .map_err(|_| anyhow::anyhow!("invalid nonce length: expected 12, got {}", nonce.len()))?;
1350
1351 let mut aad = Vec::with_capacity(export_id.len() + 1);
1353 aad.extend_from_slice(export_id);
1354 aad.push(slot_id);
1355
1356 let dek = cipher
1357 .decrypt(
1358 Nonce::from_slice(nonce),
1359 Payload {
1360 msg: wrapped,
1361 aad: &aad,
1362 },
1363 )
1364 .map_err(|err| {
1365 let context = format!(
1377 "Key unwrapping failed for slot {} ({} bytes wrapped, {} bytes nonce, \
1378 {} bytes aad): {}",
1379 slot_id,
1380 wrapped.len(),
1381 nonce.len(),
1382 aad.len(),
1383 err
1384 );
1385 anyhow::Error::new(AeadSourceError(err)).context(context)
1386 })?;
1387
1388 let dek_len = dek.len();
1389 dek.try_into().map_err(|_| {
1390 anyhow::anyhow!(
1391 "Invalid DEK length after unwrap: expected 32, got {}",
1392 dek_len
1393 )
1394 })
1395}
1396
1397#[tracing::instrument(
1410 name = "derive_chunk_nonce",
1411 skip_all,
1412 fields(operation = "derive_chunk_nonce", chunk_index = chunk_index)
1413)]
1414fn derive_chunk_nonce(base_nonce: &[u8; 12], chunk_index: u32) -> [u8; 12] {
1415 let mut nonce = *base_nonce;
1416 nonce[8..12].copy_from_slice(&chunk_index.to_be_bytes());
1419 tracing::trace!(
1420 target: "cass::pages::encrypt",
1421 operation = "derive_chunk_nonce",
1422 chunk_index = chunk_index,
1423 "derive_chunk_nonce: ok"
1424 );
1425 nonce
1426}
1427
1428fn build_chunk_aad(export_id: &[u8; 16], chunk_index: u32) -> Vec<u8> {
1430 let mut aad = Vec::with_capacity(21);
1431 aad.extend_from_slice(export_id);
1432 aad.extend_from_slice(&chunk_index.to_be_bytes());
1433 aad.push(SCHEMA_VERSION);
1434 aad
1435}
1436
1437pub fn load_config<P: AsRef<Path>>(dir: P) -> Result<EncryptionConfig> {
1439 let archive_dir = super::resolve_site_dir(dir.as_ref())?;
1440 let config_path = archive_dir.join("config.json");
1441 let file = File::open(&config_path).context("Failed to open config.json")?;
1442 let config: EncryptionConfig = serde_json::from_reader(BufReader::new(file))?;
1443 Ok(config)
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449 use tempfile::TempDir;
1450
1451 fn assert_file_bytes(path: &Path, expected: &[u8]) {
1452 let actual = std::fs::read(path)
1453 .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
1454 assert_eq!(
1455 actual.as_slice(),
1456 expected,
1457 "unexpected bytes in {}",
1458 path.display()
1459 );
1460 }
1461
1462 fn legacy_pid_backup_path(path: &Path, purpose: &str) -> Result<PathBuf> {
1463 let file_name = path
1464 .file_name()
1465 .ok_or_else(|| anyhow::anyhow!("test path must name a file"))?
1466 .to_string_lossy();
1467 Ok(output_parent(path).join(format!(".{file_name}.{purpose}.{}", std::process::id())))
1468 }
1469
1470 fn encrypt_test_file() -> (TempDir, std::path::PathBuf, EncryptionConfig) {
1471 let temp_dir = TempDir::new().unwrap();
1472 let input_path = temp_dir.path().join("input.txt");
1473 let output_dir = temp_dir.path().join("encrypted");
1474
1475 std::fs::write(&input_path, b"payload format validation test").unwrap();
1476
1477 let mut engine = EncryptionEngine::new(1024).unwrap();
1478 engine.add_password_slot("password").unwrap();
1479 let config = engine
1480 .encrypt_file(&input_path, &output_dir, |_, _| {})
1481 .unwrap();
1482
1483 (temp_dir, output_dir, config)
1484 }
1485
1486 #[test]
1487 fn test_argon2id_key_derivation() {
1488 let password = "test-password-123";
1489 let salt = b"0123456789abcdef";
1490
1491 let kek1 = derive_kek_argon2id(password, salt).unwrap();
1492 let kek2 = derive_kek_argon2id(password, salt).unwrap();
1493
1494 assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1496
1497 let kek3 = derive_kek_argon2id("different", salt).unwrap();
1499 assert_ne!(kek1.as_bytes(), kek3.as_bytes());
1500 }
1501
1502 #[test]
1503 fn test_hkdf_key_derivation() {
1504 let secret = b"recovery-secret-bytes";
1505 let salt = [0u8; 16];
1506
1507 let kek1 = derive_kek_hkdf(secret, &salt).unwrap();
1508 let kek2 = derive_kek_hkdf(secret, &salt).unwrap();
1509
1510 assert_eq!(kek1.as_bytes(), kek2.as_bytes());
1511 }
1512
1513 #[test]
1514 fn test_key_wrap_unwrap() {
1515 let kek = SecretKey::random();
1516 let dek = [42u8; 32];
1517 let export_id = [1u8; 16];
1518 let slot_id = 0;
1519
1520 let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).unwrap();
1521 let unwrapped = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id).unwrap();
1522
1523 assert_eq!(dek, unwrapped);
1524 }
1525
1526 #[test]
1527 fn test_key_wrap_wrong_aad_fails() {
1528 let kek = SecretKey::random();
1529 let dek = [42u8; 32];
1530 let export_id = [1u8; 16];
1531
1532 let (wrapped, nonce) = wrap_key(&kek, &dek, &export_id, 0).unwrap();
1533
1534 assert!(unwrap_key(&kek, &wrapped, &nonce, &export_id, 1).is_err());
1536
1537 let wrong_id = [2u8; 16];
1539 assert!(unwrap_key(&kek, &wrapped, &nonce, &wrong_id, 0).is_err());
1540 }
1541
1542 #[test]
1543 fn test_chunk_nonce_derivation() {
1544 let base = [0u8; 12];
1545
1546 let n0 = derive_chunk_nonce(&base, 0);
1547 let n1 = derive_chunk_nonce(&base, 1);
1548 let n2 = derive_chunk_nonce(&base, 2);
1549
1550 assert_ne!(n0, n1);
1552 assert_ne!(n1, n2);
1553 assert_ne!(n0, n2);
1554 }
1555
1556 #[test]
1557 fn test_encryption_roundtrip() {
1558 let temp_dir = TempDir::new().unwrap();
1559 let input_path = temp_dir.path().join("input.txt");
1560 let output_dir = temp_dir.path().join("encrypted");
1561 let decrypted_path = temp_dir.path().join("decrypted.txt");
1562
1563 let test_data = b"Hello, World! This is a test of the encryption system.";
1565 std::fs::write(&input_path, test_data).unwrap();
1566
1567 let mut engine = EncryptionEngine::new(1024).unwrap(); engine.add_password_slot("test-password").unwrap();
1570
1571 let config = engine
1572 .encrypt_file(&input_path, &output_dir, |_, _| {})
1573 .unwrap();
1574
1575 assert_eq!(config.version, SCHEMA_VERSION);
1576 assert!(!config.key_slots.is_empty());
1577 assert!(config.payload.chunk_count > 0);
1578
1579 let decryptor = DecryptionEngine::unlock_with_password(config, "test-password").unwrap();
1581 decryptor
1582 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1583 .unwrap();
1584
1585 assert_file_bytes(&decrypted_path, test_data);
1587 }
1588
1589 #[test]
1590 fn encrypt_file_rejects_chunk_count_beyond_nonce_space_before_writing_payload() {
1591 let temp_dir = TempDir::new().unwrap();
1592 let input_path = temp_dir.path().join("too-large.bin");
1593 let output_dir = temp_dir.path().join("encrypted");
1594
1595 let input = File::create(&input_path).unwrap();
1596 input.set_len(u64::from(u32::MAX) + 1).unwrap();
1597
1598 let mut engine = EncryptionEngine::new(1).unwrap();
1599 engine.add_password_slot("password").unwrap();
1600
1601 let err = engine
1602 .encrypt_file(&input_path, &output_dir, |_, _| {})
1603 .expect_err("archive must reject more than u32::MAX chunks");
1604 let rendered = err.to_string();
1605 assert!(
1606 rendered.contains("exceeds maximum") && rendered.contains(&u32::MAX.to_string()),
1607 "unexpected chunk-count error: {rendered}"
1608 );
1609 assert!(
1610 !output_dir.join("payload/chunk-00000.bin").exists(),
1611 "oversized sparse input must fail before writing any ciphertext chunk"
1612 );
1613 }
1614
1615 #[test]
1616 #[cfg(unix)]
1617 fn encrypt_file_rejects_symlinked_payload_directory() {
1618 use std::os::unix::fs::symlink;
1619
1620 let temp_dir = TempDir::new().unwrap();
1621 let input_path = temp_dir.path().join("input.txt");
1622 let output_dir = temp_dir.path().join("encrypted");
1623 let outside_dir = temp_dir.path().join("outside");
1624 let test_data = b"payload dir symlink regression data";
1625
1626 std::fs::write(&input_path, test_data).unwrap();
1627 std::fs::create_dir_all(&output_dir).unwrap();
1628 std::fs::create_dir_all(&outside_dir).unwrap();
1629 symlink(&outside_dir, output_dir.join("payload")).unwrap();
1630
1631 let mut engine = EncryptionEngine::new(1024).unwrap();
1632 engine.add_password_slot("test-password").unwrap();
1633 let err = engine
1634 .encrypt_file(&input_path, &output_dir, |_, _| {})
1635 .expect_err("symlinked payload directory should be rejected");
1636
1637 assert!(
1638 err.to_string().contains("must not contain symlinks"),
1639 "unexpected error: {err:#}"
1640 );
1641 assert!(
1642 !outside_dir.join("chunk-00000.bin").exists(),
1643 "encrypt_file must not write through a symlinked payload directory"
1644 );
1645 }
1646
1647 #[test]
1648 #[cfg(unix)]
1649 fn encrypt_file_rejects_symlinked_chunk_file_without_touching_target() {
1650 use std::os::unix::fs::symlink;
1651
1652 let temp_dir = TempDir::new().unwrap();
1653 let input_path = temp_dir.path().join("input.txt");
1654 let output_dir = temp_dir.path().join("encrypted");
1655 let payload_dir = output_dir.join("payload");
1656 let protected_target_path = temp_dir.path().join("protected.bin");
1657 let test_data = b"chunk file symlink regression data";
1658
1659 std::fs::write(&input_path, test_data).unwrap();
1660 std::fs::create_dir_all(&payload_dir).unwrap();
1661 std::fs::write(&protected_target_path, b"protected chunk target").unwrap();
1662 symlink(&protected_target_path, payload_dir.join("chunk-00000.bin")).unwrap();
1663
1664 let mut engine = EncryptionEngine::new(1024).unwrap();
1665 engine.add_password_slot("test-password").unwrap();
1666 let err = engine
1667 .encrypt_file(&input_path, &output_dir, |_, _| {})
1668 .expect_err("symlinked chunk file should be rejected");
1669
1670 assert!(
1671 err.to_string().contains("through symlink"),
1672 "unexpected error: {err:#}"
1673 );
1674 assert_file_bytes(&protected_target_path, b"protected chunk target");
1675 }
1676
1677 #[test]
1678 #[cfg(unix)]
1679 fn encrypt_file_rejects_symlinked_config_file_without_touching_target() {
1680 use std::os::unix::fs::symlink;
1681
1682 let temp_dir = TempDir::new().unwrap();
1683 let input_path = temp_dir.path().join("input.txt");
1684 let output_dir = temp_dir.path().join("encrypted");
1685 let protected_target_path = temp_dir.path().join("protected-config.json");
1686 let test_data = b"config symlink regression data";
1687
1688 std::fs::write(&input_path, test_data).unwrap();
1689 std::fs::create_dir_all(&output_dir).unwrap();
1690 std::fs::write(&protected_target_path, b"protected config target").unwrap();
1691 symlink(&protected_target_path, output_dir.join("config.json")).unwrap();
1692
1693 let mut engine = EncryptionEngine::new(1024).unwrap();
1694 engine.add_password_slot("test-password").unwrap();
1695 let err = engine
1696 .encrypt_file(&input_path, &output_dir, |_, _| {})
1697 .expect_err("symlinked config file should be rejected");
1698
1699 assert!(
1700 err.to_string().contains("through symlink"),
1701 "unexpected error: {err:#}"
1702 );
1703 assert_file_bytes(&protected_target_path, b"protected config target");
1704 }
1705
1706 #[test]
1707 fn archive_backup_replace_does_not_collide_with_stale_pid_sidecar() -> Result<()> {
1708 let temp_dir = TempDir::new()?;
1709 let final_path = temp_dir.path().join("config.json");
1710 let temp_path = temp_dir.path().join(".config.json.tmp");
1711 let stale_backup = legacy_pid_backup_path(&final_path, "cass-encrypt-backup")?;
1712
1713 std::fs::write(&final_path, b"old config")?;
1714 std::fs::write(&temp_path, b"new config")?;
1715 std::fs::write(&stale_backup, b"stale backup")?;
1716
1717 replace_archive_file_from_temp_via_backup(
1718 &temp_path,
1719 &final_path,
1720 "test encryption config",
1721 )?;
1722
1723 if !matches!(
1724 std::fs::read(&final_path)?.as_slice().cmp(b"new config"),
1725 std::cmp::Ordering::Equal
1726 ) {
1727 return Err(anyhow::anyhow!(
1728 "archive replacement did not publish temp bytes"
1729 ));
1730 }
1731 if !matches!(
1732 std::fs::read(&stale_backup)?
1733 .as_slice()
1734 .cmp(b"stale backup"),
1735 std::cmp::Ordering::Equal
1736 ) {
1737 return Err(anyhow::anyhow!(
1738 "archive replacement clobbered stale backup"
1739 ));
1740 }
1741 if temp_path.exists() {
1742 return Err(anyhow::anyhow!("archive temp path was not consumed"));
1743 }
1744
1745 Ok(())
1746 }
1747
1748 #[cfg(unix)]
1749 #[test]
1750 fn replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
1751 use std::os::unix::fs::symlink;
1752
1753 let temp_dir = TempDir::new()?;
1754 let link_path = temp_dir.path().join("decrypted.txt");
1755 let missing_target = temp_dir.path().join("missing-target.txt");
1756
1757 symlink(&missing_target, &link_path)?;
1758
1759 if link_path.exists() {
1760 return Err(anyhow::anyhow!(
1761 "Path::exists stopped following the missing target"
1762 ));
1763 }
1764 if !replacement_path_entry_exists(&link_path, "test output")? {
1765 return Err(anyhow::anyhow!(
1766 "replacement path helper missed a dangling symlink entry"
1767 ));
1768 }
1769
1770 Ok(())
1771 }
1772
1773 #[test]
1774 fn test_multiple_key_slots() {
1775 let temp_dir = TempDir::new().unwrap();
1776 let input_path = temp_dir.path().join("input.txt");
1777 let output_dir = temp_dir.path().join("encrypted");
1778 let decrypted_path = temp_dir.path().join("decrypted.txt");
1779
1780 let test_data = b"Multi-slot test data";
1781 std::fs::write(&input_path, test_data).unwrap();
1782
1783 let mut engine = EncryptionEngine::new(1024).unwrap();
1785 engine.add_password_slot("password1").unwrap();
1786 engine.add_password_slot("password2").unwrap();
1787 engine.add_recovery_slot(b"recovery-secret").unwrap();
1788
1789 let config = engine
1790 .encrypt_file(&input_path, &output_dir, |_, _| {})
1791 .unwrap();
1792
1793 assert_eq!(config.key_slots.len(), 3);
1794
1795 let d1 = DecryptionEngine::unlock_with_password(config.clone(), "password1").unwrap();
1797 d1.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1798 .unwrap();
1799 assert_file_bytes(&decrypted_path, test_data);
1800
1801 let d2 = DecryptionEngine::unlock_with_password(config.clone(), "password2").unwrap();
1803 d2.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1804 .unwrap();
1805 assert_file_bytes(&decrypted_path, test_data);
1806
1807 let d3 =
1809 DecryptionEngine::unlock_with_recovery(config.clone(), b"recovery-secret").unwrap();
1810 d3.decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1811 .unwrap();
1812 assert_file_bytes(&decrypted_path, test_data);
1813
1814 assert!(DecryptionEngine::unlock_with_password(config, "wrong").is_err());
1816 }
1817
1818 #[test]
1819 fn key_slot_id_for_len_rejects_overflow() {
1820 assert_eq!(key_slot_id_for_len(255).unwrap(), 255);
1821
1822 let err = key_slot_id_for_len(256).unwrap_err();
1823 assert_eq!(
1824 err.to_string(),
1825 "maximum of 256 key slots exceeded (256 slots already allocated): out of range integral type conversion attempted"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_load_config_and_decrypt_accept_bundle_root() {
1831 let temp_dir = TempDir::new().unwrap();
1832 let input_path = temp_dir.path().join("input.txt");
1833 let bundle_root = temp_dir.path().join("bundle");
1834 let site_dir = bundle_root.join("site");
1835 let decrypted_path = temp_dir.path().join("decrypted.txt");
1836
1837 let test_data = b"Bundle root decryption test data";
1838 std::fs::write(&input_path, test_data).unwrap();
1839
1840 let mut engine = EncryptionEngine::new(1024).unwrap();
1841 engine.add_password_slot("password").unwrap();
1842 engine
1843 .encrypt_file(&input_path, &site_dir, |_, _| {})
1844 .unwrap();
1845
1846 let config = load_config(&bundle_root).unwrap();
1847 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1848 decryptor
1849 .decrypt_to_file(&bundle_root, &decrypted_path, |_, _| {})
1850 .unwrap();
1851
1852 assert_file_bytes(&decrypted_path, test_data);
1853 }
1854
1855 #[test]
1856 fn test_decrypt_rejects_unsupported_payload_compression_before_unlock() {
1857 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1858 config.compression = "zstd".to_string();
1859
1860 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1861 Ok(_) => panic!("unsupported compression must fail before unlock"),
1862 Err(err) => err,
1863 };
1864
1865 let rendered = err.to_string();
1866 assert!(
1867 rendered.contains("supports only deflate") && rendered.contains("zstd"),
1868 "unexpected unsupported-compression error: {err:#}"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_decrypt_rejects_unsupported_schema_version_before_unlock() {
1874 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1875 config.version = 1;
1876
1877 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1878 Ok(_) => panic!("unsupported schema version must fail before unlock"),
1879 Err(err) => err,
1880 };
1881
1882 let rendered = err.to_string();
1883 assert!(
1884 rendered.contains("schema version") && rendered.contains("expected 2"),
1885 "unexpected unsupported-version error: {err:#}"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_decrypt_rejects_mismatched_chunk_count_before_unlock() {
1891 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1892 config.payload.chunk_count += 1;
1893
1894 let err = match DecryptionEngine::unlock_with_password(config, "password") {
1895 Ok(_) => panic!("mismatched chunk count must fail before unlock"),
1896 Err(err) => err,
1897 };
1898
1899 let rendered = err.to_string();
1900 assert!(
1901 rendered.contains("chunk_count") && rendered.contains("file list length"),
1902 "unexpected mismatched-chunk-count error: {err:#}"
1903 );
1904 }
1905
1906 #[test]
1907 fn test_validate_rejects_unexpected_payload_file_name() -> Result<()> {
1908 let (_temp_dir, _output_dir, mut config) = encrypt_test_file();
1909 let first_file = config
1910 .payload
1911 .files
1912 .first_mut()
1913 .context("test archive should include one payload file")?;
1914 *first_file = "payload/chunk-99999.bin".to_string();
1915
1916 let err = validate_supported_payload_format(&config)
1917 .err()
1918 .context("unexpected payload file name must fail validation")?;
1919 let rendered = err.to_string();
1920 if !rendered.contains("payload file entry 0")
1921 || !rendered.contains("payload/chunk-00000.bin")
1922 {
1923 bail!("unexpected payload-file-name error: {err:#}");
1924 }
1925
1926 Ok(())
1927 }
1928
1929 #[test]
1930 fn test_tampered_chunk_fails() {
1931 let temp_dir = TempDir::new().unwrap();
1932 let input_path = temp_dir.path().join("input.txt");
1933 let output_dir = temp_dir.path().join("encrypted");
1934 let decrypted_path = temp_dir.path().join("decrypted.txt");
1935
1936 std::fs::write(&input_path, b"Test data for tampering").unwrap();
1937
1938 let mut engine = EncryptionEngine::new(1024).unwrap();
1939 engine.add_password_slot("password").unwrap();
1940
1941 let config = engine
1942 .encrypt_file(&input_path, &output_dir, |_, _| {})
1943 .unwrap();
1944
1945 let chunk_path = output_dir.join("payload/chunk-00000.bin");
1947 let mut chunk_data = std::fs::read(&chunk_path).unwrap();
1948 chunk_data[0] ^= 0xFF; std::fs::write(&chunk_path, &chunk_data).unwrap();
1950
1951 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1953 assert!(
1954 decryptor
1955 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1956 .is_err()
1957 );
1958 }
1959
1960 #[test]
1961 fn decrypt_to_file_preserves_existing_output_when_later_chunk_fails() {
1962 let temp_dir = TempDir::new().unwrap();
1963 let input_path = temp_dir.path().join("input.txt");
1964 let output_dir = temp_dir.path().join("encrypted");
1965 let decrypted_path = temp_dir.path().join("decrypted.txt");
1966
1967 let test_data: Vec<u8> = (0..4096).map(|idx| (idx % 251) as u8).collect();
1968 std::fs::write(&input_path, &test_data).unwrap();
1969
1970 let mut engine = EncryptionEngine::new(32).unwrap();
1971 engine.add_password_slot("password").unwrap();
1972 let config = engine
1973 .encrypt_file(&input_path, &output_dir, |_, _| {})
1974 .unwrap();
1975 assert!(
1976 config.payload.chunk_count > 1,
1977 "test must produce multiple chunks to exercise partial-write failure"
1978 );
1979
1980 let existing_output = b"existing decrypted output must survive failed decrypt";
1981 std::fs::write(&decrypted_path, existing_output).unwrap();
1982
1983 let second_chunk_path = output_dir.join("payload/chunk-00001.bin");
1984 let mut second_chunk = std::fs::read(&second_chunk_path).unwrap();
1985 let last = second_chunk.len() - 1;
1986 second_chunk[last] ^= 0x55;
1987 std::fs::write(&second_chunk_path, &second_chunk).unwrap();
1988
1989 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
1990 let err = decryptor
1991 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
1992 .expect_err("tampered later chunk must fail");
1993 assert!(
1994 err.to_string().contains("Decryption failed for chunk 1"),
1995 "unexpected decrypt error: {err:#}"
1996 );
1997 assert_file_bytes(&decrypted_path, existing_output);
1998
1999 let leaked_temp = std::fs::read_dir(temp_dir.path())
2000 .unwrap()
2001 .filter_map(Result::ok)
2002 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2003 .any(|name| name.contains(".cass-decrypt-tmp."));
2004 assert!(
2005 !leaked_temp,
2006 "failed decrypt should not leave plaintext temp files"
2007 );
2008 }
2009
2010 #[test]
2011 #[cfg(unix)]
2012 fn decrypt_to_file_replaces_output_symlink_without_touching_target() {
2013 use std::os::unix::fs::symlink;
2014
2015 let temp_dir = TempDir::new().unwrap();
2016 let input_path = temp_dir.path().join("input.txt");
2017 let output_dir = temp_dir.path().join("encrypted");
2018 let protected_target_path = temp_dir.path().join("protected.txt");
2019 let decrypted_path = temp_dir.path().join("decrypted.txt");
2020 let test_data = b"symlink output regression data";
2021
2022 std::fs::write(&input_path, test_data).unwrap();
2023 std::fs::write(&protected_target_path, b"protected target").unwrap();
2024 symlink(&protected_target_path, &decrypted_path).unwrap();
2025
2026 let mut engine = EncryptionEngine::new(1024).unwrap();
2027 engine.add_password_slot("password").unwrap();
2028 let config = engine
2029 .encrypt_file(&input_path, &output_dir, |_, _| {})
2030 .unwrap();
2031
2032 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
2033 decryptor
2034 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
2035 .unwrap();
2036
2037 assert_file_bytes(&protected_target_path, b"protected target");
2038 let metadata = std::fs::symlink_metadata(&decrypted_path).unwrap();
2039 assert!(
2040 !metadata.file_type().is_symlink(),
2041 "successful decrypt should replace the output symlink itself"
2042 );
2043 assert_file_bytes(&decrypted_path, test_data);
2044 }
2045
2046 #[test]
2047 fn decrypt_backup_replace_does_not_collide_with_stale_pid_sidecar() -> Result<()> {
2048 let temp_dir = TempDir::new()?;
2049 let output_path = temp_dir.path().join("decrypted.txt");
2050 let temp_path = temp_dir.path().join(".decrypted.txt.tmp");
2051 let stale_backup = legacy_pid_backup_path(&output_path, "cass-decrypt-backup")?;
2052
2053 std::fs::write(&output_path, b"old plaintext")?;
2054 std::fs::write(&temp_path, b"new plaintext")?;
2055 std::fs::write(&stale_backup, b"stale decrypt backup")?;
2056
2057 replace_decrypt_output_from_temp_via_backup(&temp_path, &output_path)?;
2058
2059 if !matches!(
2060 std::fs::read(&output_path)?
2061 .as_slice()
2062 .cmp(b"new plaintext"),
2063 std::cmp::Ordering::Equal
2064 ) {
2065 return Err(anyhow::anyhow!(
2066 "decrypt replacement did not publish temp bytes"
2067 ));
2068 }
2069 if !matches!(
2070 std::fs::read(&stale_backup)?
2071 .as_slice()
2072 .cmp(b"stale decrypt backup"),
2073 std::cmp::Ordering::Equal
2074 ) {
2075 return Err(anyhow::anyhow!(
2076 "decrypt replacement clobbered stale backup"
2077 ));
2078 }
2079 if temp_path.exists() {
2080 return Err(anyhow::anyhow!("decrypt temp path was not consumed"));
2081 }
2082
2083 Ok(())
2084 }
2085
2086 #[test]
2087 #[cfg(unix)]
2088 fn decrypt_to_file_replacement_keeps_plaintext_output_private() {
2089 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
2090
2091 let temp_dir = TempDir::new().unwrap();
2092 let input_path = temp_dir.path().join("input.txt");
2093 let output_dir = temp_dir.path().join("encrypted");
2094 let decrypted_path = temp_dir.path().join("decrypted.txt");
2095 let test_data = b"private replacement mode regression data";
2096
2097 std::fs::write(&input_path, test_data).unwrap();
2098 let mut existing = OpenOptions::new()
2099 .write(true)
2100 .create_new(true)
2101 .mode(0o600)
2102 .open(&decrypted_path)
2103 .unwrap();
2104 existing.write_all(b"old private plaintext").unwrap();
2105 existing.sync_all().unwrap();
2106 drop(existing);
2107
2108 let mut engine = EncryptionEngine::new(1024).unwrap();
2109 engine.add_password_slot("password").unwrap();
2110 let config = engine
2111 .encrypt_file(&input_path, &output_dir, |_, _| {})
2112 .unwrap();
2113
2114 let decryptor = DecryptionEngine::unlock_with_password(config, "password").unwrap();
2115 decryptor
2116 .decrypt_to_file(&output_dir, &decrypted_path, |_, _| {})
2117 .unwrap();
2118
2119 assert_file_bytes(&decrypted_path, test_data);
2120 let mode = std::fs::metadata(&decrypted_path)
2121 .unwrap()
2122 .permissions()
2123 .mode()
2124 & 0o777;
2125 assert_eq!(
2126 mode, 0o600,
2127 "decrypted plaintext output should not gain group/other permissions"
2128 );
2129 }
2130
2131 #[test]
2132 fn test_encryption_engine_rejects_zero_chunk_size() {
2133 let err = EncryptionEngine::new(0).unwrap_err();
2134 assert!(err.to_string().contains("chunk_size"));
2135 }
2136
2137 #[test]
2138 fn test_encryption_engine_rejects_oversized_chunk_size() {
2139 let err = EncryptionEngine::new(MAX_CHUNK_SIZE + 1).unwrap_err();
2140 assert!(err.to_string().contains("chunk_size"));
2141 }
2142
2143 #[cfg(not(windows))]
2166 #[test]
2167 fn sync_tree_includes_parent_directory_fsync() {
2168 use std::fs;
2169 let tmp = tempfile::TempDir::new().expect("tempdir");
2170 let archive_dir = tmp.path().join("archive");
2171 fs::create_dir_all(&archive_dir).expect("create archive dir");
2172 fs::write(archive_dir.join("index.html"), b"<html></html>").unwrap();
2173 fs::write(archive_dir.join("chunk-0.bin"), [0u8; 16]).unwrap();
2174 let nested = archive_dir.join("assets");
2175 fs::create_dir_all(&nested).expect("create nested");
2176 fs::write(nested.join("style.css"), b"body{}").unwrap();
2177
2178 sync_tree(&archive_dir).expect("happy-path sync_tree must succeed");
2183
2184 let doomed_parent = tmp.path().join("doomed-parent");
2203 fs::create_dir_all(&doomed_parent).expect("create doomed parent");
2204 fs::write(doomed_parent.join("payload"), b"payload").unwrap();
2205 fs::remove_dir_all(&doomed_parent).expect("remove doomed parent");
2206 let err = sync_tree(&doomed_parent).expect_err(
2209 "sync_tree on a vanished directory must surface an I/O error; \
2210 silent Ok(()) would mean the fsync stack is a stub",
2211 );
2212 let err_str = err.to_string();
2213 assert!(
2214 err_str.contains("No such")
2215 || err_str.contains("not found")
2216 || err_str.contains("vanished")
2217 || err_str.contains("doomed"),
2218 "sync_tree error must reference the missing path or NotFound: got {err_str}"
2219 );
2220 }
2221
2222 #[test]
2241 fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2242 let kek = SecretKey::from_bytes([0u8; 32]);
2243 let dek = [0u8; 32];
2244 let export_id = [42u8; 16];
2245 let slot_id = 7u8;
2246
2247 let (mut wrapped, nonce) = wrap_key(&kek, &dek, &export_id, slot_id).expect("wrap_key");
2249
2250 let last = wrapped.len() - 1;
2255 wrapped[last] ^= 0x55;
2256
2257 let err = unwrap_key(&kek, &wrapped, &nonce, &export_id, slot_id)
2258 .expect_err("tampered ciphertext must fail unwrap");
2259 let rendered = err.to_string();
2260
2261 assert!(
2263 rendered.contains(&format!("slot {slot_id}")),
2264 "unwrap error must name the slot id; got: {rendered}"
2265 );
2266 assert!(
2268 rendered.contains(&format!("{} bytes wrapped", wrapped.len())),
2269 "unwrap error must include the wrapped-ciphertext length; got: {rendered}"
2270 );
2271 assert!(
2272 rendered.contains("12 bytes nonce"),
2273 "unwrap error must include the AES-GCM nonce length; got: {rendered}"
2274 );
2275 assert!(
2282 rendered.contains(": "),
2283 "unwrap error must include `: <source>` separator so the \
2284 aead source error survives in the chain; got: {rendered}"
2285 );
2286 let chain: Vec<String> = err.chain().map(ToString::to_string).collect();
2287 assert!(
2288 chain.len() >= 2,
2289 "unwrap error must preserve the aead source as an anyhow chain frame; \
2290 got chain: {chain:?}"
2291 );
2292 assert!(
2293 chain.iter().skip(1).any(|frame| !frame.is_empty()),
2294 "unwrap error source frame must be non-empty for debug inspection; \
2295 got chain: {chain:?}"
2296 );
2297 assert!(
2301 rendered.contains("Key unwrapping failed"),
2302 "unwrap error must keep the human-facing prefix for runbook \
2303 grep compatibility; got: {rendered}"
2304 );
2305 }
2306
2307 #[test]
2316 fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2317 let actual_kek = crate::encryption::hkdf_extract_expand(
2323 b"recovery-secret",
2324 b"salty-salty-salty-salt",
2325 b"cass-pages-kek-v2",
2326 16, )
2328 .expect("hkdf with 16-byte output must succeed");
2329 let actual_len = actual_kek.len();
2330 assert_eq!(actual_len, 16);
2331
2332 let conversion: Result<[u8; 32], Vec<u8>> = actual_kek.try_into();
2334 let raw_err = conversion.expect_err("16 != 32 must fail try_into");
2335 assert_eq!(raw_err.len(), 16);
2336
2337 let rendered = format!(
2343 "HKDF expansion produced invalid KEK length: expected 32, got {}",
2344 raw_err.len()
2345 );
2346 assert!(rendered.contains("expected 32"));
2347 assert!(rendered.contains("got 16"));
2348 }
2349}