1use crate::pages::attachments::reencrypt_blobs_into_dir;
19use crate::pages::encrypt::{
20 Argon2Params, EncryptionConfig, KdfAlgorithm, KeySlot, SlotType, load_config,
21 validate_supported_payload_format,
22};
23use crate::pages::qr::RecoverySecret;
24use aes_gcm::{
25 Aes256Gcm, Nonce,
26 aead::{Aead, KeyInit, Payload},
27};
28use anyhow::{Context, Result, bail};
29use argon2::{Algorithm, Argon2, Params, Version};
30use base64::prelude::*;
31use chrono::{DateTime, Utc};
32use flate2::{Compression, read::DeflateDecoder, write::DeflateEncoder};
33use rand::Rng;
34use serde::Serialize;
35use std::fs::File;
36use std::io::{BufWriter, Read, Write};
37use std::path::{Component, Path, PathBuf};
38use tracing::info;
39
40#[cfg(not(test))]
42const ARGON2_MEMORY_KB: u32 = 65536; #[cfg(test)]
44const ARGON2_MEMORY_KB: u32 = 64;
45#[cfg(not(test))]
46const ARGON2_ITERATIONS: u32 = 3;
47#[cfg(test)]
48const ARGON2_ITERATIONS: u32 = 1;
49#[cfg(not(test))]
50const ARGON2_PARALLELISM: u32 = 4;
51#[cfg(test)]
52const ARGON2_PARALLELISM: u32 = 1;
53
54const SCHEMA_VERSION: u8 = 2;
56const MAX_ARCHIVE_CHUNKS: u64 = u32::MAX as u64;
57const REQUIRED_SITE_FILES: &[&str] = &[
58 "index.html",
59 "config.json",
60 "sw.js",
61 "viewer.js",
62 "auth.js",
63 "styles.css",
64 "robots.txt",
65 ".nojekyll",
66];
67
68fn max_encryptable_plaintext_bytes(chunk_size: usize) -> u64 {
69 MAX_ARCHIVE_CHUNKS.saturating_mul(chunk_size as u64)
70}
71
72fn ensure_archive_chunk_count_fits_nonce_space(chunk_count: u64, chunk_size: usize) -> Result<()> {
73 if chunk_count > MAX_ARCHIVE_CHUNKS {
74 bail!(
75 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
76 u32::MAX,
77 max_encryptable_plaintext_bytes(chunk_size)
78 );
79 }
80 Ok(())
81}
82
83fn ensure_can_write_archive_chunk(chunk_index: u32, chunk_size: usize) -> Result<()> {
84 if chunk_index == u32::MAX {
85 bail!(
86 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
87 u32::MAX,
88 max_encryptable_plaintext_bytes(chunk_size)
89 );
90 }
91 Ok(())
92}
93
94#[derive(Debug, Clone, Serialize)]
96pub struct KeyListResult {
97 pub slots: Vec<KeySlotInfo>,
98 pub active_slots: usize,
99 pub dek_created_at: Option<String>,
100 pub export_id: String,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct KeySlotInfo {
106 pub id: u8,
107 pub slot_type: String,
108 pub kdf: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub kdf_params: Option<Argon2Params>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub label: Option<String>,
113}
114
115#[derive(Debug)]
117pub enum AddKeyResult {
118 Password { slot_id: u8 },
119 Recovery { slot_id: u8, secret: RecoverySecret },
120}
121
122#[derive(Debug, Serialize)]
124pub struct RevokeResult {
125 pub revoked_slot_id: u8,
126 pub remaining_slots: usize,
127}
128
129#[derive(Debug, Serialize)]
131pub struct RotateResult {
132 pub new_dek_created_at: DateTime<Utc>,
133 pub slot_count: usize,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub recovery_secret: Option<String>,
136}
137
138pub fn key_list(archive_dir: &Path) -> Result<KeyListResult> {
140 let archive_dir = super::resolve_site_dir(archive_dir)?;
141 let config = load_config(&archive_dir)?;
142
143 let slots: Vec<KeySlotInfo> = config
144 .key_slots
145 .iter()
146 .map(|slot| KeySlotInfo {
147 id: slot.id,
148 slot_type: match slot.slot_type {
149 SlotType::Password => "password".to_string(),
150 SlotType::Recovery => "recovery".to_string(),
151 },
152 kdf: match slot.kdf {
153 KdfAlgorithm::Argon2id => "argon2id".to_string(),
154 KdfAlgorithm::HkdfSha256 => "hkdf-sha256".to_string(),
155 },
156 kdf_params: slot.argon2_params.clone(),
157 label: None, })
159 .collect();
160
161 Ok(KeyListResult {
162 active_slots: slots.len(),
163 slots,
164 dek_created_at: None, export_id: config.export_id,
166 })
167}
168
169pub fn key_add_password(
171 archive_dir: &Path,
172 current_password: &str,
173 new_password: &str,
174) -> Result<u8> {
175 let archive_dir = super::resolve_site_dir(archive_dir)?;
176 let config_path = archive_dir.join("config.json");
177 let mut config = load_config(&archive_dir)?;
178 validate_supported_payload_format(&config)?;
179
180 let dek = zeroize::Zeroizing::new(unwrap_dek_with_password(&config, current_password)?);
182
183 let slot_id = next_key_slot_id(&config.key_slots)?;
186 let new_slot = create_password_slot(new_password, &dek, &config.export_id, slot_id)?;
187
188 materialize_safe_required_file_symlinks(&archive_dir)?;
189 config.key_slots.push(new_slot);
190
191 write_json_pretty_atomically(&config_path, &config)?;
193
194 let manifest = regenerate_integrity_manifest(&archive_dir)?;
196 refresh_private_artifacts(&archive_dir, &config, manifest.as_ref(), None, false)?;
197
198 info!(slot_id, "Added password key slot");
199 Ok(slot_id)
200}
201
202pub fn key_add_recovery(
204 archive_dir: &Path,
205 current_password: &str,
206) -> Result<(u8, RecoverySecret)> {
207 let archive_dir = super::resolve_site_dir(archive_dir)?;
208 let config_path = archive_dir.join("config.json");
209 let mut config = load_config(&archive_dir)?;
210 validate_supported_payload_format(&config)?;
211
212 let dek = zeroize::Zeroizing::new(unwrap_dek_with_password(&config, current_password)?);
214
215 let secret = RecoverySecret::generate();
217
218 let slot_id = next_key_slot_id(&config.key_slots)?;
221 let new_slot = create_recovery_slot(secret.as_bytes(), &dek, &config.export_id, slot_id)?;
222
223 materialize_safe_required_file_symlinks(&archive_dir)?;
224 config.key_slots.push(new_slot);
225
226 write_json_pretty_atomically(&config_path, &config)?;
228
229 let manifest = regenerate_integrity_manifest(&archive_dir)?;
231 refresh_private_artifacts(
232 &archive_dir,
233 &config,
234 manifest.as_ref(),
235 Some(secret.as_bytes()),
236 false,
237 )?;
238
239 info!(slot_id, "Added recovery key slot");
240 Ok((slot_id, secret))
241}
242
243fn next_key_slot_id(key_slots: &[KeySlot]) -> Result<u8> {
244 match key_slots.iter().map(|s| s.id).max() {
245 Some(max_id) => max_id.checked_add(1).ok_or_else(|| {
246 anyhow::anyhow!("Cannot add more key slots: maximum slot ID (255) reached")
247 }),
248 None => Ok(0),
249 }
250}
251
252pub fn key_revoke(
254 archive_dir: &Path,
255 current_password: &str,
256 slot_id_to_revoke: u8,
257) -> Result<RevokeResult> {
258 let archive_dir = super::resolve_site_dir(archive_dir)?;
259 let config_path = archive_dir.join("config.json");
260 let mut config = load_config(&archive_dir)?;
261 validate_supported_payload_format(&config)?;
262
263 if config.key_slots.len() <= 1 {
265 anyhow::bail!("Cannot revoke the last remaining key slot. Add another key first.");
266 }
267
268 let (auth_slot_id, dek) = unwrap_dek_with_slot_id(&config, current_password)?;
270 let mut _dek = zeroize::Zeroizing::new(dek); if auth_slot_id == slot_id_to_revoke {
274 bail!(
275 "Cannot revoke slot {} used for authentication. Use a different password.",
276 slot_id_to_revoke
277 );
278 }
279
280 if !config.key_slots.iter().any(|s| s.id == slot_id_to_revoke) {
282 bail!("Slot {} not found", slot_id_to_revoke);
283 }
284
285 let revoked_slot_is_recovery = config
286 .key_slots
287 .iter()
288 .find(|s| s.id == slot_id_to_revoke)
289 .map(|s| s.slot_type == SlotType::Recovery)
290 .unwrap_or(false);
291
292 materialize_safe_required_file_symlinks(&archive_dir)?;
293
294 config.key_slots.retain(|s| s.id != slot_id_to_revoke);
296
297 write_json_pretty_atomically(&config_path, &config)?;
299
300 let manifest = regenerate_integrity_manifest(&archive_dir)?;
302 let has_recovery_slot = config
303 .key_slots
304 .iter()
305 .any(|slot| slot.slot_type == SlotType::Recovery);
306 refresh_private_artifacts(
307 &archive_dir,
308 &config,
309 manifest.as_ref(),
310 None,
311 revoked_slot_is_recovery || !has_recovery_slot,
312 )?;
313
314 info!(slot_id = slot_id_to_revoke, "Revoked key slot");
315 Ok(RevokeResult {
316 revoked_slot_id: slot_id_to_revoke,
317 remaining_slots: config.key_slots.len(),
318 })
319}
320
321pub fn key_rotate(
323 archive_dir: &Path,
324 old_password: &str,
325 new_password: &str,
326 keep_recovery: bool,
327 progress: impl Fn(f32),
328) -> Result<RotateResult> {
329 let archive_dir = super::resolve_site_dir(archive_dir)?;
330 let config = load_config(&archive_dir)?;
331 validate_supported_payload_format(&config)?;
332 let old_export_id_raw = BASE64_STANDARD.decode(&config.export_id)?;
333 let old_export_id: [u8; 16] = old_export_id_raw.as_slice().try_into().map_err(|err| {
334 anyhow::anyhow!(
339 "invalid export_id length: expected 16, got {}: {err}",
340 old_export_id_raw.len()
341 )
342 })?;
343
344 let old_dek = zeroize::Zeroizing::new(unwrap_dek_with_password(&config, old_password)?);
346 let plaintext =
347 zeroize::Zeroizing::new(decrypt_all_chunks(&archive_dir, &old_dek, &config, |p| {
348 progress(p * 0.5)
349 })?);
350
351 let mut new_dek = zeroize::Zeroizing::new([0u8; 32]);
353 let mut new_export_id = [0u8; 16];
354 let mut new_base_nonce = [0u8; 12];
355 let mut rng = rand::rng();
356 rng.fill_bytes(new_dek.as_mut());
357 rng.fill_bytes(&mut new_export_id);
358 rng.fill_bytes(&mut new_base_nonce);
359
360 let staged_site_dir = unique_atomic_sidecar_path(&archive_dir, "rotate", "site");
361 copy_site_except_runtime_state(&archive_dir, &staged_site_dir)?;
362
363 let chunk_count = encrypt_all_chunks(
365 &plaintext,
366 &new_dek,
367 &new_export_id,
368 &new_base_nonce,
369 config.payload.chunk_size,
370 &staged_site_dir.join("payload"),
371 |p| progress(0.5 + p * 0.5),
372 )?;
373
374 reencrypt_blobs_into_dir(
375 &archive_dir,
376 &staged_site_dir,
377 &old_dek,
378 &old_export_id,
379 &new_dek,
380 &new_export_id,
381 )?;
382
383 let mut new_slots = vec![create_password_slot(
385 new_password,
386 &new_dek,
387 &BASE64_STANDARD.encode(new_export_id),
388 0,
389 )?];
390
391 let mut recovery_secret_encoded: Option<String> = None;
392 let mut recovery_secret_bytes: Option<Vec<u8>> = None;
393 if keep_recovery {
394 let secret = RecoverySecret::generate();
395 new_slots.push(create_recovery_slot(
396 secret.as_bytes(),
397 &new_dek,
398 &BASE64_STANDARD.encode(new_export_id),
399 1,
400 )?);
401 recovery_secret_bytes = Some(secret.as_bytes().to_vec());
402 recovery_secret_encoded = Some(secret.encoded().to_string());
403 }
404
405 let new_config = EncryptionConfig {
407 version: config.version,
408 export_id: BASE64_STANDARD.encode(new_export_id),
409 base_nonce: BASE64_STANDARD.encode(new_base_nonce),
410 compression: config.compression,
411 kdf_defaults: Argon2Params::default(),
412 payload: crate::pages::encrypt::PayloadMeta {
413 chunk_size: config.payload.chunk_size,
414 chunk_count,
415 total_compressed_size: 0, total_plaintext_size: plaintext.len() as u64,
417 files: (0..chunk_count)
418 .map(|i| format!("payload/chunk-{:05}.bin", i))
419 .collect(),
420 },
421 key_slots: new_slots.clone(),
422 };
423
424 write_json_pretty(&staged_site_dir.join("config.json"), &new_config)?;
425
426 let manifest = crate::pages::bundle::generate_integrity_manifest(&staged_site_dir)?;
428 write_json_pretty(&staged_site_dir.join("integrity.json"), &manifest)?;
429 sync_tree(&staged_site_dir)?;
430 replace_dir_from_temp(&staged_site_dir, &archive_dir)?;
431 refresh_private_artifacts(
432 &archive_dir,
433 &new_config,
434 Some(&manifest),
435 recovery_secret_bytes.as_deref(),
436 !keep_recovery,
437 )?;
438
439 Ok(RotateResult {
440 new_dek_created_at: chrono::Utc::now(),
441 slot_count: new_slots.len(),
442 recovery_secret: recovery_secret_encoded,
443 })
444}
445
446fn unwrap_dek_with_password(config: &EncryptionConfig, password: &str) -> Result<[u8; 32]> {
452 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
453
454 for slot in &config.key_slots {
455 if slot.slot_type != SlotType::Password {
456 continue;
457 }
458
459 let salt = BASE64_STANDARD.decode(&slot.salt)?;
460 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
461 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
462
463 if let Ok(kek) = derive_kek_argon2id(password, &salt) {
464 let result = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id);
465 if let Ok(dek) = result {
466 return Ok(dek);
467 }
468 }
469 }
470
471 bail!("Invalid password or no matching key slot")
472}
473
474fn unwrap_dek_with_slot_id(config: &EncryptionConfig, password: &str) -> Result<(u8, [u8; 32])> {
476 let export_id = BASE64_STANDARD.decode(&config.export_id)?;
477
478 for slot in &config.key_slots {
479 if slot.slot_type != SlotType::Password {
480 continue;
481 }
482
483 let salt = BASE64_STANDARD.decode(&slot.salt)?;
484 let wrapped_dek = BASE64_STANDARD.decode(&slot.wrapped_dek)?;
485 let nonce = BASE64_STANDARD.decode(&slot.nonce)?;
486
487 if let Ok(kek) = derive_kek_argon2id(password, &salt) {
488 let result = unwrap_key(&kek, &wrapped_dek, &nonce, &export_id, slot.id);
489 if let Ok(dek) = result {
490 return Ok((slot.id, dek));
491 }
492 }
493 }
494
495 bail!("Invalid password or no matching key slot")
496}
497
498fn derive_kek_argon2id(password: &str, salt: &[u8]) -> Result<zeroize::Zeroizing<[u8; 32]>> {
500 let params = Params::new(
501 ARGON2_MEMORY_KB,
502 ARGON2_ITERATIONS,
503 ARGON2_PARALLELISM,
504 Some(32),
505 )
506 .map_err(|e| anyhow::anyhow!("Invalid Argon2 parameters: {:?}", e))?;
507
508 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
509
510 let mut kek = zeroize::Zeroizing::new([0u8; 32]);
511 argon2
512 .hash_password_into(password.as_bytes(), salt, kek.as_mut())
513 .map_err(|e| anyhow::anyhow!("Argon2 derivation failed: {:?}", e))?;
514
515 Ok(kek)
516}
517
518fn derive_kek_hkdf(secret: &[u8], salt: &[u8]) -> Result<zeroize::Zeroizing<[u8; 32]>> {
520 let kek = crate::encryption::hkdf_extract_expand(secret, salt, b"cass-pages-kek-v2", 32)
521 .map_err(|e| anyhow::anyhow!("HKDF extract+expand failed for recovery secret KEK: {e}"))?;
522 let actual_len = kek.len();
523 let kek: [u8; 32] = kek.try_into().map_err(|_| {
524 anyhow::anyhow!(
529 "HKDF expansion produced invalid KEK length: expected 32, got {}",
530 actual_len
531 )
532 })?;
533 Ok(zeroize::Zeroizing::new(kek))
534}
535
536fn unwrap_key(
538 kek: &[u8; 32],
539 wrapped: &[u8],
540 nonce: &[u8],
541 export_id: &[u8],
542 slot_id: u8,
543) -> Result<[u8; 32]> {
544 let cipher = Aes256Gcm::new_from_slice(kek).expect("Invalid key length");
545
546 let actual_nonce_len = nonce.len();
547 let nonce: &[u8; 12] = nonce.try_into().map_err(|err| {
548 anyhow::anyhow!(
552 "invalid nonce length: expected 12, got {}: {err}",
553 actual_nonce_len
554 )
555 })?;
556
557 let mut aad = Vec::with_capacity(export_id.len() + 1);
559 aad.extend_from_slice(export_id);
560 aad.push(slot_id);
561
562 let dek = cipher
563 .decrypt(
564 Nonce::from_slice(nonce),
565 Payload {
566 msg: wrapped,
567 aad: &aad,
568 },
569 )
570 .map_err(|err| {
571 anyhow::anyhow!(
584 "Key unwrapping failed for slot {} ({} bytes wrapped, {} bytes nonce, \
585 {} bytes aad): {}",
586 slot_id,
587 wrapped.len(),
588 actual_nonce_len,
589 aad.len(),
590 err
591 )
592 })?;
593
594 let dek_len = dek.len();
595 dek.try_into().map_err(|_| {
596 anyhow::anyhow!(
597 "Invalid DEK length after unwrap: expected 32, got {}",
598 dek_len
599 )
600 })
601}
602
603fn create_password_slot(
605 password: &str,
606 dek: &[u8; 32],
607 export_id_b64: &str,
608 slot_id: u8,
609) -> Result<KeySlot> {
610 let export_id = BASE64_STANDARD.decode(export_id_b64)?;
611
612 let mut salt = [0u8; 32];
614 let mut rng = rand::rng();
615 rng.fill_bytes(&mut salt);
616
617 let kek = derive_kek_argon2id(password, &salt)?;
619
620 let result = wrap_key(&kek, dek, &export_id, slot_id);
622
623 let (wrapped_dek, nonce) = result?;
624
625 Ok(KeySlot {
626 id: slot_id,
627 slot_type: SlotType::Password,
628 kdf: KdfAlgorithm::Argon2id,
629 salt: BASE64_STANDARD.encode(salt),
630 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
631 nonce: BASE64_STANDARD.encode(nonce),
632 argon2_params: Some(Argon2Params::default()),
633 })
634}
635
636fn create_recovery_slot(
638 secret: &[u8],
639 dek: &[u8; 32],
640 export_id_b64: &str,
641 slot_id: u8,
642) -> Result<KeySlot> {
643 let export_id = BASE64_STANDARD.decode(export_id_b64)?;
644
645 let mut salt = [0u8; 16];
647 let mut rng = rand::rng();
648 rng.fill_bytes(&mut salt);
649
650 let kek = derive_kek_hkdf(secret, &salt)?;
652
653 let result = wrap_key(&kek, dek, &export_id, slot_id);
655
656 let (wrapped_dek, nonce) = result?;
657
658 Ok(KeySlot {
659 id: slot_id,
660 slot_type: SlotType::Recovery,
661 kdf: KdfAlgorithm::HkdfSha256,
662 salt: BASE64_STANDARD.encode(salt),
663 wrapped_dek: BASE64_STANDARD.encode(&wrapped_dek),
664 nonce: BASE64_STANDARD.encode(nonce),
665 argon2_params: None,
666 })
667}
668
669fn wrap_key(
671 kek: &[u8; 32],
672 dek: &[u8; 32],
673 export_id: &[u8],
674 slot_id: u8,
675) -> Result<(Vec<u8>, [u8; 12])> {
676 let cipher = Aes256Gcm::new_from_slice(kek).expect("Invalid key length");
677
678 let mut nonce = [0u8; 12];
679 let mut rng = rand::rng();
680 rng.fill_bytes(&mut nonce);
681
682 let mut aad = Vec::with_capacity(export_id.len() + 1);
684 aad.extend_from_slice(export_id);
685 aad.push(slot_id);
686
687 let wrapped = cipher
688 .encrypt(
689 Nonce::from_slice(&nonce),
690 Payload {
691 msg: dek,
692 aad: &aad,
693 },
694 )
695 .map_err(|e| anyhow::anyhow!("Key wrapping failed: {}", e))?;
696
697 Ok((wrapped, nonce))
698}
699
700fn decrypt_all_chunks(
702 archive_dir: &Path,
703 dek: &[u8; 32],
704 config: &EncryptionConfig,
705 progress: impl Fn(f32),
706) -> Result<Vec<u8>> {
707 let cipher = Aes256Gcm::new_from_slice(dek).expect("Invalid key length");
708 let base_nonce_raw = BASE64_STANDARD.decode(&config.base_nonce)?;
709 let base_nonce: [u8; 12] = base_nonce_raw.as_slice().try_into().map_err(|err| {
710 anyhow::anyhow!(
714 "invalid base_nonce length: expected 12, got {}: {err}",
715 base_nonce_raw.len()
716 )
717 })?;
718 let export_id_raw = BASE64_STANDARD.decode(&config.export_id)?;
719 let export_id: [u8; 16] = export_id_raw.as_slice().try_into().map_err(|err| {
720 anyhow::anyhow!(
722 "invalid export_id length: expected 16, got {}: {err}",
723 export_id_raw.len()
724 )
725 })?;
726 let canonical_archive_dir = archive_dir.canonicalize().with_context(|| {
727 format!(
728 "Failed to resolve archive root {} before decrypting chunks",
729 archive_dir.display()
730 )
731 })?;
732
733 let mut plaintext = Vec::new();
734
735 if config.payload.chunk_count != config.payload.files.len() {
736 bail!(
737 "Invalid config: payload chunk_count {} does not match file list length {}",
738 config.payload.chunk_count,
739 config.payload.files.len()
740 );
741 }
742
743 for (chunk_index, chunk_file) in config.payload.files.iter().enumerate() {
744 progress(chunk_index as f32 / config.payload.chunk_count as f32);
745
746 let expected_chunk_file = format!("payload/chunk-{chunk_index:05}.bin");
747 if chunk_file != &expected_chunk_file {
748 bail!(
749 "Invalid chunk path in config.json: expected {}, got {}",
750 expected_chunk_file,
751 chunk_file
752 );
753 }
754 let chunk_path = archive_dir.join(chunk_file);
755 let chunk_meta = std::fs::symlink_metadata(&chunk_path).with_context(|| {
756 format!(
757 "Failed to inspect encrypted chunk {} at {}",
758 chunk_index,
759 chunk_path.display()
760 )
761 })?;
762 if chunk_meta.file_type().is_symlink() {
763 bail!("Encrypted chunk must not be a symlink: {}", chunk_file);
764 }
765 if !chunk_meta.file_type().is_file() {
766 bail!("Encrypted chunk must be a regular file: {}", chunk_file);
767 }
768
769 let canonical_chunk_path = chunk_path.canonicalize().with_context(|| {
770 format!(
771 "Failed to resolve encrypted chunk {} at {}",
772 chunk_index,
773 chunk_path.display()
774 )
775 })?;
776 if !canonical_chunk_path.starts_with(&canonical_archive_dir) {
777 bail!(
778 "Encrypted chunk path escapes archive directory: {}",
779 chunk_file
780 );
781 }
782
783 let ciphertext = std::fs::read(&canonical_chunk_path)?;
784
785 let nonce = derive_chunk_nonce(&base_nonce, chunk_index as u32);
787
788 let aad = build_chunk_aad(&export_id, chunk_index as u32);
790
791 let compressed = cipher
793 .decrypt(
794 Nonce::from_slice(&nonce),
795 Payload {
796 msg: &ciphertext,
797 aad: &aad,
798 },
799 )
800 .map_err(|err| {
801 anyhow::anyhow!(
809 "Decryption failed for chunk {} ({} bytes ciphertext): {}",
810 chunk_index,
811 ciphertext.len(),
812 err
813 )
814 })?;
815
816 let mut decoder = DeflateDecoder::new(&compressed[..]);
818 let mut chunk_plaintext = Vec::new();
819 decoder.read_to_end(&mut chunk_plaintext)?;
820
821 plaintext.extend(chunk_plaintext);
822 }
823
824 progress(1.0);
825 Ok(plaintext)
826}
827
828fn encrypt_all_chunks(
830 plaintext: &[u8],
831 dek: &[u8; 32],
832 export_id: &[u8; 16],
833 base_nonce: &[u8; 12],
834 chunk_size: usize,
835 payload_dir: &Path,
836 progress: impl Fn(f32),
837) -> Result<usize> {
838 std::fs::create_dir_all(payload_dir)?;
839
840 let cipher = Aes256Gcm::new_from_slice(dek).expect("Invalid key length");
841 if chunk_size == 0 {
842 anyhow::bail!("chunk_size must be > 0");
843 }
844 let total_chunks = plaintext.len().div_ceil(chunk_size);
845 ensure_archive_chunk_count_fits_nonce_space(total_chunks as u64, chunk_size)?;
846 let mut chunk_index = 0u32;
847
848 for (i, chunk) in plaintext.chunks(chunk_size).enumerate() {
849 progress(i as f32 / total_chunks as f32);
850 ensure_can_write_archive_chunk(chunk_index, chunk_size)?;
851
852 let mut compressed = Vec::new();
854 {
855 let mut encoder = DeflateEncoder::new(&mut compressed, Compression::default());
856 encoder.write_all(chunk)?;
857 encoder.finish()?;
858 }
859
860 let nonce = derive_chunk_nonce(base_nonce, chunk_index);
862
863 let aad = build_chunk_aad(export_id, chunk_index);
865
866 let ciphertext = cipher
868 .encrypt(
869 Nonce::from_slice(&nonce),
870 Payload {
871 msg: &compressed,
872 aad: &aad,
873 },
874 )
875 .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
876
877 let chunk_filename = format!("chunk-{:05}.bin", chunk_index);
879 let chunk_path = payload_dir.join(&chunk_filename);
880 let mut chunk_file = File::create(&chunk_path)?;
881 chunk_file.write_all(&ciphertext)?;
882
883 chunk_index = chunk_index.checked_add(1).ok_or_else(|| {
884 anyhow::anyhow!(
885 "File too large: exceeds maximum of {} chunks ({} bytes with current chunk size)",
886 u32::MAX,
887 (u32::MAX as u64) * (chunk_size as u64)
888 )
889 })?;
890 }
891
892 progress(1.0);
893 Ok(chunk_index as usize)
894}
895
896fn derive_chunk_nonce(base_nonce: &[u8; 12], chunk_index: u32) -> [u8; 12] {
898 let mut nonce = *base_nonce;
899 nonce[8..12].copy_from_slice(&chunk_index.to_be_bytes());
901 nonce
902}
903
904fn build_chunk_aad(export_id: &[u8; 16], chunk_index: u32) -> Vec<u8> {
906 let mut aad = Vec::with_capacity(21);
907 aad.extend_from_slice(export_id);
908 aad.extend_from_slice(&chunk_index.to_be_bytes());
909 aad.push(SCHEMA_VERSION);
910 aad
911}
912
913fn regenerate_integrity_manifest(
915 archive_dir: &Path,
916) -> Result<Option<crate::pages::bundle::IntegrityManifest>> {
917 let integrity_path = archive_dir.join("integrity.json");
918 if !integrity_path.exists() {
919 return Ok(None);
920 }
921
922 let integrity = crate::pages::bundle::generate_integrity_manifest(archive_dir)?;
923 write_json_pretty(&integrity_path, &integrity)?;
924
925 Ok(Some(integrity))
926}
927
928fn materialize_safe_required_file_symlinks(archive_dir: &Path) -> Result<()> {
929 let canonical_archive_dir = archive_dir.canonicalize().with_context(|| {
930 format!(
931 "Failed to resolve archive directory {} before key mutation",
932 archive_dir.display()
933 )
934 })?;
935
936 for rel_path in REQUIRED_SITE_FILES {
937 materialize_safe_required_file_symlink(archive_dir, &canonical_archive_dir, rel_path)?;
938 }
939
940 Ok(())
941}
942
943fn materialize_safe_required_file_symlink(
944 archive_dir: &Path,
945 canonical_archive_dir: &Path,
946 rel_path: &str,
947) -> Result<()> {
948 let path = archive_dir.join(rel_path);
949 let metadata = match std::fs::symlink_metadata(&path) {
950 Ok(metadata) => metadata,
951 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
952 Err(err) => {
953 return Err(err).with_context(|| {
954 format!(
955 "Failed to inspect required site file {} before key mutation",
956 path.display()
957 )
958 });
959 }
960 };
961 if !metadata.file_type().is_symlink() {
962 return Ok(());
963 }
964
965 let canonical_target = path.canonicalize().with_context(|| {
966 format!(
967 "Failed to resolve symlinked required site file {} before key mutation",
968 path.display()
969 )
970 })?;
971 if !canonical_target.starts_with(canonical_archive_dir) {
972 bail!(
973 "Refusing to materialize required site file symlink outside archive root: {}",
974 path.display()
975 );
976 }
977
978 let target_metadata = std::fs::metadata(&canonical_target).with_context(|| {
979 format!(
980 "Failed to inspect symlink target {} before key mutation",
981 canonical_target.display()
982 )
983 })?;
984 if !target_metadata.file_type().is_file() {
985 bail!(
986 "Refusing to materialize required site file symlink that does not point to a regular file: {}",
987 path.display()
988 );
989 }
990
991 let temp_path = unique_atomic_sidecar_path(&path, "materialize", "site-file");
992 std::fs::copy(&canonical_target, &temp_path).with_context(|| {
993 format!(
994 "Failed copying symlink target {} into staged required site file {}",
995 canonical_target.display(),
996 temp_path.display()
997 )
998 })?;
999 File::open(&temp_path)
1000 .and_then(|file| file.sync_all())
1001 .with_context(|| {
1002 format!(
1003 "Failed syncing materialized required site file {}",
1004 temp_path.display()
1005 )
1006 })?;
1007 replace_file_from_temp(&temp_path, &path).with_context(|| {
1008 format!(
1009 "Failed materializing required site file symlink {} before key mutation",
1010 path.display()
1011 )
1012 })?;
1013
1014 Ok(())
1015}
1016
1017fn write_json_pretty_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1018 let temp_path = unique_atomic_temp_path(path);
1019 {
1020 let file = File::create(&temp_path)?;
1021 let mut writer = BufWriter::new(file);
1022 serde_json::to_writer_pretty(&mut writer, value)?;
1023 writer.flush()?;
1024 writer.get_ref().sync_all()?;
1025 }
1026 replace_file_from_temp(&temp_path, path)
1027}
1028
1029fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1030 let file = File::create(path)?;
1031 let mut writer = BufWriter::new(file);
1032 serde_json::to_writer_pretty(&mut writer, value)?;
1033 writer.flush()?;
1034 writer.get_ref().sync_all()?;
1035 Ok(())
1036}
1037
1038fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<()> {
1039 if cfg!(windows) {
1040 match std::fs::rename(temp_path, final_path) {
1041 Ok(()) => {
1042 sync_parent_directory(final_path)?;
1043 Ok(())
1044 }
1045 Err(first_err) if final_path.exists() => {
1046 let backup_path = unique_atomic_backup_path(final_path);
1047 std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
1048 let _ = std::fs::remove_file(temp_path);
1049 anyhow::anyhow!(
1050 "failed replacing {} with {}: {}; failed moving existing file to backup {}: {}",
1051 final_path.display(),
1052 temp_path.display(),
1053 first_err,
1054 backup_path.display(),
1055 backup_err
1056 )
1057 })?;
1058
1059 match std::fs::rename(temp_path, final_path) {
1060 Ok(()) => {
1061 let _ = std::fs::remove_file(&backup_path);
1062 sync_parent_directory(final_path)?;
1063 Ok(())
1064 }
1065 Err(second_err) => match std::fs::rename(&backup_path, final_path) {
1066 Ok(()) => {
1067 let _ = std::fs::remove_file(temp_path);
1068 sync_parent_directory(final_path)?;
1069 anyhow::bail!(
1070 "failed replacing {} with {}: {}; restored original file",
1071 final_path.display(),
1072 temp_path.display(),
1073 second_err
1074 );
1075 }
1076 Err(restore_err) => {
1077 anyhow::bail!(
1078 "failed replacing {} with {}: {}; restore error: {}; temp file retained at {}",
1079 final_path.display(),
1080 temp_path.display(),
1081 second_err,
1082 restore_err,
1083 temp_path.display()
1084 );
1085 }
1086 },
1087 }
1088 }
1089 Err(err) => Err(err.into()),
1090 }
1091 } else {
1092 std::fs::rename(temp_path, final_path)?;
1093 sync_parent_directory(final_path)?;
1094 Ok(())
1095 }
1096}
1097
1098#[cfg(not(windows))]
1099fn sync_parent_directory(path: &Path) -> Result<()> {
1100 let Some(parent) = path.parent() else {
1101 return Ok(());
1102 };
1103 std::fs::File::open(parent)?.sync_all()?;
1104 Ok(())
1105}
1106
1107#[cfg(windows)]
1108fn sync_parent_directory(_path: &Path) -> Result<()> {
1109 Ok(())
1110}
1111
1112fn unique_atomic_temp_path(path: &Path) -> std::path::PathBuf {
1113 unique_atomic_sidecar_path(path, "tmp", "config.json")
1114}
1115
1116fn unique_atomic_backup_path(path: &Path) -> std::path::PathBuf {
1117 unique_atomic_sidecar_path(path, "bak", "config.json")
1118}
1119
1120fn unique_atomic_sidecar_path(
1121 path: &Path,
1122 suffix: &str,
1123 fallback_name: &str,
1124) -> std::path::PathBuf {
1125 static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1126
1127 let timestamp = std::time::SystemTime::now()
1128 .duration_since(std::time::UNIX_EPOCH)
1129 .unwrap_or_default()
1130 .as_nanos();
1131 let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1132 let file_name = path
1133 .file_name()
1134 .and_then(|name| name.to_str())
1135 .unwrap_or(fallback_name);
1136
1137 path.with_file_name(format!(
1138 ".{file_name}.{suffix}.{}.{}.{}",
1139 std::process::id(),
1140 timestamp,
1141 nonce
1142 ))
1143}
1144
1145fn replace_dir_from_temp(temp_dir: &Path, final_dir: &Path) -> Result<()> {
1146 if !ensure_replaceable_site_dir(final_dir)? {
1147 std::fs::rename(temp_dir, final_dir).with_context(|| {
1148 format!(
1149 "failed renaming staged site {} into place at {}",
1150 temp_dir.display(),
1151 final_dir.display()
1152 )
1153 })?;
1154 sync_parent_directory(final_dir)?;
1155 return Ok(());
1156 }
1157
1158 let backup_dir = unique_atomic_sidecar_path(final_dir, "bak", "site");
1159 std::fs::rename(final_dir, &backup_dir).with_context(|| {
1160 format!(
1161 "failed preparing backup {} before replacing {}",
1162 backup_dir.display(),
1163 final_dir.display()
1164 )
1165 })?;
1166
1167 match std::fs::rename(temp_dir, final_dir) {
1168 Ok(()) => {
1169 sync_parent_directory(final_dir)?;
1170 let _ = std::fs::remove_dir_all(&backup_dir);
1171 sync_parent_directory(final_dir)?;
1172 Ok(())
1173 }
1174 Err(second_err) => match std::fs::rename(&backup_dir, final_dir) {
1175 Ok(()) => {
1176 let _ = std::fs::remove_dir_all(temp_dir);
1177 sync_parent_directory(final_dir)?;
1178 anyhow::bail!(
1179 "failed replacing {} with {}: {}; restored original site",
1180 final_dir.display(),
1181 temp_dir.display(),
1182 second_err
1183 )
1184 }
1185 Err(restore_err) => anyhow::bail!(
1186 "failed replacing {} with {}: {}; restore error: {}; staged site retained at {}",
1187 final_dir.display(),
1188 temp_dir.display(),
1189 second_err,
1190 restore_err,
1191 temp_dir.display()
1192 ),
1193 },
1194 }
1195}
1196
1197fn ensure_replaceable_site_dir(path: &Path) -> Result<bool> {
1198 match std::fs::symlink_metadata(path) {
1199 Ok(metadata) => {
1200 let file_type = metadata.file_type();
1201 if file_type.is_symlink() {
1202 bail!(
1203 "Refusing to replace site directory through symlink: {}",
1204 path.display()
1205 );
1206 }
1207 if !file_type.is_dir() {
1208 bail!(
1209 "Refusing to replace site directory because it is not a directory: {}",
1210 path.display()
1211 );
1212 }
1213 Ok(true)
1214 }
1215 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
1216 Err(err) => Err(err).with_context(|| {
1217 format!(
1218 "Failed inspecting site directory before replacement: {}",
1219 path.display()
1220 )
1221 }),
1222 }
1223}
1224
1225#[cfg(not(windows))]
1226fn sync_tree(path: &Path) -> Result<()> {
1227 sync_tree_inner(path)?;
1228 sync_parent_directory(path)
1229}
1230
1231#[cfg(windows)]
1232fn sync_tree(_path: &Path) -> Result<()> {
1233 Ok(())
1234}
1235
1236#[cfg(not(windows))]
1237fn sync_tree_inner(path: &Path) -> Result<()> {
1238 let metadata = std::fs::symlink_metadata(path)
1239 .with_context(|| format!("Failed reading metadata for {}", path.display()))?;
1240 let file_type = metadata.file_type();
1241 if file_type.is_symlink() {
1242 return Ok(());
1243 }
1244 if file_type.is_file() {
1245 std::fs::File::open(path)
1246 .with_context(|| format!("Failed opening {} for sync", path.display()))?
1247 .sync_all()
1248 .with_context(|| format!("Failed syncing {}", path.display()))?;
1249 return Ok(());
1250 }
1251 if file_type.is_dir() {
1252 for entry in std::fs::read_dir(path)
1253 .with_context(|| format!("Failed reading directory {}", path.display()))?
1254 {
1255 let entry = entry.with_context(|| format!("Failed walking {}", path.display()))?;
1256 sync_tree_inner(&entry.path())?;
1257 }
1258 std::fs::File::open(path)
1259 .with_context(|| format!("Failed opening directory {} for sync", path.display()))?
1260 .sync_all()
1261 .with_context(|| format!("Failed syncing directory {}", path.display()))?;
1262 }
1263 Ok(())
1264}
1265
1266fn copy_site_except_runtime_state(src: &Path, dst: &Path) -> Result<()> {
1267 std::fs::create_dir_all(dst)
1268 .with_context(|| format!("Failed to create staged site directory {}", dst.display()))?;
1269 let canonical_base = src.canonicalize().with_context(|| {
1270 format!(
1271 "Failed to resolve archive root {} before staging key rotation",
1272 src.display()
1273 )
1274 })?;
1275 copy_site_except_runtime_state_recursive(src, dst, src, &canonical_base)
1276}
1277
1278fn safe_staged_site_destination(dst_root: &Path, rel_path: &Path) -> Result<PathBuf> {
1279 let mut path_parts = vec![dst_root.to_path_buf()];
1280 for component in rel_path.components() {
1281 match component {
1282 Component::CurDir => {}
1283 Component::Normal(name) => path_parts.push(PathBuf::from(name)),
1284 _ => bail!(
1285 "Refusing to stage archive entry with unsafe relative path: {}",
1286 rel_path.display()
1287 ),
1288 }
1289 }
1290 Ok(path_parts.into_iter().collect())
1291}
1292
1293fn copy_site_except_runtime_state_recursive(
1294 src: &Path,
1295 dst: &Path,
1296 base: &Path,
1297 canonical_base: &Path,
1298) -> Result<()> {
1299 for entry in std::fs::read_dir(src)? {
1300 let entry = entry?;
1301 let path = entry.path();
1302 let rel_path = path.strip_prefix(base)?;
1303 let skip_root_entry = rel_path.components().count() == 1
1304 && matches!(
1305 rel_path.to_str(),
1306 Some("payload" | "blobs" | "config.json" | "integrity.json")
1307 );
1308 if skip_root_entry {
1309 continue;
1310 }
1311
1312 let metadata = std::fs::symlink_metadata(&path)?;
1313 let file_type = metadata.file_type();
1314 let dest_path = safe_staged_site_destination(dst, rel_path)?;
1315 if file_type.is_dir() {
1316 std::fs::create_dir_all(&dest_path)?;
1317 copy_site_except_runtime_state_recursive(&path, dst, base, canonical_base)?;
1318 } else if file_type.is_symlink() {
1319 let canonical_target = path.canonicalize().with_context(|| {
1320 format!(
1321 "Failed to resolve symlinked site entry {} while staging key rotation",
1322 rel_path.display()
1323 )
1324 })?;
1325 if !canonical_target.starts_with(canonical_base) {
1326 bail!(
1327 "Refusing to rotate symlinked site entry outside archive root: {}",
1328 rel_path.display()
1329 );
1330 }
1331
1332 let target_meta = std::fs::metadata(&path).with_context(|| {
1333 format!(
1334 "Failed to read symlink target metadata for {} while staging key rotation",
1335 rel_path.display()
1336 )
1337 })?;
1338 if !target_meta.is_file() {
1339 bail!(
1340 "Refusing to rotate symlinked site entry that does not point to a regular file: {}",
1341 rel_path.display()
1342 );
1343 }
1344
1345 if let Some(parent) = dest_path.parent() {
1346 std::fs::create_dir_all(parent)?;
1347 }
1348 std::fs::copy(&canonical_target, &dest_path).with_context(|| {
1351 format!(
1352 "Failed copying symlink target {} into staged site path {}",
1353 canonical_target.display(),
1354 dest_path.display()
1355 )
1356 })?;
1357 } else if file_type.is_file() {
1358 if let Some(parent) = dest_path.parent() {
1359 std::fs::create_dir_all(parent)?;
1360 }
1361 std::fs::copy(&path, &dest_path).with_context(|| {
1362 format!(
1363 "Failed copying staged site file {} to {}",
1364 path.display(),
1365 dest_path.display()
1366 )
1367 })?;
1368 }
1369 }
1370
1371 Ok(())
1372}
1373
1374fn refresh_private_artifacts(
1375 archive_dir: &Path,
1376 config: &EncryptionConfig,
1377 manifest: Option<&crate::pages::bundle::IntegrityManifest>,
1378 recovery_secret: Option<&[u8]>,
1379 remove_recovery_artifacts: bool,
1380) -> Result<()> {
1381 let Some(private_dir) = private_dir_for_archive(archive_dir)? else {
1382 return Ok(());
1383 };
1384
1385 if let Some(manifest) = manifest {
1386 let fingerprint = crate::pages::bundle::compute_fingerprint(manifest);
1387 crate::pages::bundle::write_private_fingerprint(&private_dir, &fingerprint)?;
1388 }
1389
1390 let should_generate_qr = recovery_secret.is_some()
1391 && (private_dir.join("qr-code.png").exists() || private_dir.join("qr-code.svg").exists());
1392
1393 crate::pages::bundle::write_private_artifacts_encrypted(
1394 &private_dir,
1395 config,
1396 recovery_secret,
1397 should_generate_qr,
1398 remove_recovery_artifacts,
1399 )?;
1400
1401 Ok(())
1402}
1403
1404fn private_dir_for_archive(archive_dir: &Path) -> Result<Option<std::path::PathBuf>> {
1405 if archive_dir
1406 .file_name()
1407 .map(|name| name == "site")
1408 .unwrap_or(false)
1409 {
1410 let Some(parent) = archive_dir.parent() else {
1411 return Ok(None);
1412 };
1413 let private_dir = parent.join("private");
1414 match std::fs::symlink_metadata(&private_dir) {
1415 Ok(metadata) => {
1416 let file_type = metadata.file_type();
1417 if file_type.is_symlink() {
1418 bail!(
1419 "private artifact directory must not be a symlink: {}",
1420 private_dir.display()
1421 );
1422 }
1423 if file_type.is_dir() {
1424 return Ok(Some(private_dir));
1425 }
1426 bail!(
1427 "private artifact path must be a directory: {}",
1428 private_dir.display()
1429 );
1430 }
1431 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1432 Err(err) => {
1433 return Err(err).with_context(|| {
1434 format!(
1435 "Failed to inspect private artifact directory {}",
1436 private_dir.display()
1437 )
1438 });
1439 }
1440 }
1441 }
1442
1443 Ok(None)
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449 use crate::pages::attachments::{
1450 AttachmentConfig, AttachmentData, AttachmentProcessor, decrypt_blob, decrypt_manifest,
1451 };
1452 use crate::pages::bundle::BundleBuilder;
1453 use crate::pages::encrypt::{DecryptionEngine, EncryptionEngine, MAX_CHUNK_SIZE, PayloadMeta};
1454 use crate::pages::verify::verify_bundle;
1455 use std::cell::Cell;
1456 use tempfile::TempDir;
1457
1458 #[cfg(unix)]
1459 fn replace_viewer_with_in_tree_symlink(site_dir: &Path) {
1460 use std::os::unix::fs::symlink;
1461
1462 let real_viewer = site_dir.join("viewer-real.js");
1463 std::fs::rename(site_dir.join("viewer.js"), &real_viewer).unwrap();
1464 symlink("viewer-real.js", site_dir.join("viewer.js")).unwrap();
1465
1466 let manifest = crate::pages::bundle::generate_integrity_manifest(site_dir).unwrap();
1467 write_json_pretty(&site_dir.join("integrity.json"), &manifest).unwrap();
1468 }
1469
1470 fn setup_test_archive() -> (TempDir, std::path::PathBuf) {
1471 let temp_dir = TempDir::new().unwrap();
1472 let input_path = temp_dir.path().join("input.txt");
1473 let bundle_root = temp_dir.path().join("bundle");
1474 let encrypted_dir = temp_dir.path().join("encrypted");
1475
1476 std::fs::write(&input_path, b"Test data for key management").unwrap();
1478
1479 let mut engine = EncryptionEngine::new(1024).unwrap();
1481 engine.add_password_slot("test-password").unwrap();
1482 engine
1483 .encrypt_file(&input_path, &encrypted_dir, |_, _| {})
1484 .unwrap();
1485
1486 BundleBuilder::new()
1487 .build(&encrypted_dir, &bundle_root, |_, _| {})
1488 .unwrap();
1489
1490 (temp_dir, bundle_root)
1491 }
1492
1493 fn setup_test_archive_with_attachments() -> (TempDir, std::path::PathBuf) {
1494 let temp_dir = TempDir::new().unwrap();
1495 let input_path = temp_dir.path().join("input.txt");
1496 let bundle_root = temp_dir.path().join("bundle");
1497 let encrypted_dir = temp_dir.path().join("encrypted");
1498
1499 std::fs::write(&input_path, b"Test data for key management").unwrap();
1500
1501 let mut engine = EncryptionEngine::new(1024).unwrap();
1502 engine.add_password_slot("test-password").unwrap();
1503 engine
1504 .encrypt_file(&input_path, &encrypted_dir, |_, _| {})
1505 .unwrap();
1506
1507 let config = load_config(&encrypted_dir).unwrap();
1508 let dek = unwrap_dek_with_password(&config, "test-password").unwrap();
1509 let export_id_raw = BASE64_STANDARD.decode(&config.export_id).unwrap();
1510 let export_id: [u8; 16] = export_id_raw.as_slice().try_into().unwrap();
1511
1512 let mut processor = AttachmentProcessor::new(AttachmentConfig::enabled());
1513 processor
1514 .process_attachments(
1515 1,
1516 &[AttachmentData {
1517 filename: "proof.txt".to_string(),
1518 mime_type: "text/plain".to_string(),
1519 data: b"attachment payload".to_vec(),
1520 }],
1521 )
1522 .unwrap();
1523 processor
1524 .write_encrypted_blobs(&encrypted_dir, &dek, &export_id)
1525 .unwrap();
1526
1527 BundleBuilder::new()
1528 .build(&encrypted_dir, &bundle_root, |_, _| {})
1529 .unwrap();
1530
1531 (temp_dir, bundle_root)
1532 }
1533
1534 fn rewrite_test_config(archive_dir: &Path, mutate: impl FnOnce(&mut EncryptionConfig)) {
1535 let site_dir = super::super::resolve_site_dir(archive_dir).unwrap();
1536 let mut config = load_config(&site_dir).unwrap();
1537 mutate(&mut config);
1538 write_json_pretty(&site_dir.join("config.json"), &config).unwrap();
1539 }
1540
1541 fn assert_unsupported_payload_format_error(err: anyhow::Error, compression: &str) {
1542 let rendered = err.to_string();
1543 assert!(
1544 rendered.contains("supports only deflate") && rendered.contains(compression),
1545 "unexpected unsupported-format error: {err:#}"
1546 );
1547 }
1548
1549 #[test]
1550 #[cfg(unix)]
1551 fn test_private_dir_for_archive_rejects_symlinked_private_dir() {
1552 use std::os::unix::fs::symlink;
1553
1554 let temp = TempDir::new().unwrap();
1555 let site_dir = temp.path().join("bundle/site");
1556 let outside_private = temp.path().join("outside-private");
1557 std::fs::create_dir_all(&site_dir).unwrap();
1558 std::fs::create_dir_all(&outside_private).unwrap();
1559 symlink(&outside_private, temp.path().join("bundle/private")).unwrap();
1560
1561 let err = private_dir_for_archive(&site_dir).unwrap_err();
1562
1563 assert!(
1564 err.to_string().contains("must not be a symlink"),
1565 "unexpected error: {err:#}"
1566 );
1567 assert!(
1568 std::fs::symlink_metadata(temp.path().join("bundle/private"))
1569 .unwrap()
1570 .file_type()
1571 .is_symlink(),
1572 "rejected private directory symlink should remain untouched"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_private_dir_for_archive_rejects_non_directory_private_path() {
1578 let temp = TempDir::new().unwrap();
1579 let site_dir = temp.path().join("bundle/site");
1580 std::fs::create_dir_all(&site_dir).unwrap();
1581 std::fs::write(temp.path().join("bundle/private"), "not a directory").unwrap();
1582
1583 let err = private_dir_for_archive(&site_dir).unwrap_err();
1584
1585 assert!(
1586 err.to_string().contains("must be a directory"),
1587 "unexpected error: {err:#}"
1588 );
1589 assert_eq!(
1590 std::fs::read_to_string(temp.path().join("bundle/private")).unwrap(),
1591 "not a directory"
1592 );
1593 }
1594
1595 #[test]
1596 fn test_decrypt_all_chunks_rejects_mismatched_chunk_count_before_progress() {
1597 let temp_dir = TempDir::new().unwrap();
1598 let archive_dir = temp_dir.path();
1599 let config = EncryptionConfig {
1600 version: SCHEMA_VERSION,
1601 export_id: BASE64_STANDARD.encode([0u8; 16]),
1602 base_nonce: BASE64_STANDARD.encode([0u8; 12]),
1603 compression: "deflate".to_string(),
1604 kdf_defaults: Argon2Params::default(),
1605 payload: PayloadMeta {
1606 chunk_size: 1024,
1607 chunk_count: 0,
1608 total_compressed_size: 0,
1609 total_plaintext_size: 0,
1610 files: vec!["payload/chunk-00000.bin".to_string()],
1611 },
1612 key_slots: Vec::new(),
1613 };
1614 let progress_calls = Cell::new(0);
1615
1616 let err = decrypt_all_chunks(archive_dir, &[0u8; 32], &config, |progress| {
1617 assert!(progress.is_finite(), "progress must be finite: {progress}");
1618 progress_calls.set(progress_calls.get() + 1);
1619 })
1620 .unwrap_err();
1621
1622 assert!(
1623 err.to_string().contains("chunk_count 0"),
1624 "unexpected error: {err:#}"
1625 );
1626 assert_eq!(progress_calls.get(), 0);
1627 }
1628
1629 #[test]
1630 fn test_key_list() {
1631 let (_temp_dir, archive_dir) = setup_test_archive();
1632
1633 let result = key_list(&archive_dir).unwrap();
1634 assert_eq!(result.active_slots, 1);
1635 assert_eq!(result.slots.len(), 1);
1636 assert_eq!(result.slots[0].slot_type, "password");
1637 assert_eq!(result.slots[0].kdf, "argon2id");
1638 }
1639
1640 #[test]
1641 fn test_key_mutations_reject_unsupported_payload_compression() {
1642 let (_temp_dir, archive_dir) = setup_test_archive();
1643 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1644 rewrite_test_config(&archive_dir, |config| {
1645 config.compression = "zstd".to_string();
1646 });
1647
1648 let err = key_add_password(&archive_dir, "test-password", "third-password").unwrap_err();
1649 assert_unsupported_payload_format_error(err, "zstd");
1650
1651 let err = key_add_recovery(&archive_dir, "test-password").unwrap_err();
1652 assert_unsupported_payload_format_error(err, "zstd");
1653
1654 let err = key_revoke(&archive_dir, "second-password", 0).unwrap_err();
1655 assert_unsupported_payload_format_error(err, "zstd");
1656
1657 let err =
1658 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1659 assert_unsupported_payload_format_error(err, "zstd");
1660
1661 let config = load_config(&archive_dir).unwrap();
1662 assert_eq!(config.key_slots.len(), 2);
1663 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1664 assert!(unwrap_dek_with_password(&config, "second-password").is_ok());
1665 assert!(unwrap_dek_with_password(&config, "third-password").is_err());
1666 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
1667 }
1668
1669 #[test]
1670 fn test_key_rotate_rejects_oversized_payload_chunk_size_before_rewriting() {
1671 let (_temp_dir, archive_dir) = setup_test_archive();
1672 rewrite_test_config(&archive_dir, |config| {
1673 config.payload.chunk_size = MAX_CHUNK_SIZE + 1;
1674 });
1675
1676 let err =
1677 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1678 let rendered = err.to_string();
1679 assert!(
1680 rendered.contains("chunk_size") && rendered.contains("must be <="),
1681 "unexpected chunk-size error: {err:#}"
1682 );
1683
1684 let config = load_config(&archive_dir).unwrap();
1685 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1686 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
1687 }
1688
1689 #[test]
1690 fn test_key_rotate_chunk_count_preflight_preserves_nonce_space_limit() {
1691 ensure_archive_chunk_count_fits_nonce_space(u64::from(u32::MAX), 1).unwrap();
1692
1693 let err =
1694 ensure_archive_chunk_count_fits_nonce_space(u64::from(u32::MAX) + 1, 1).unwrap_err();
1695 let rendered = err.to_string();
1696 assert!(
1697 rendered.contains("exceeds maximum") && rendered.contains(&u32::MAX.to_string()),
1698 "unexpected chunk-count error: {rendered}"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_key_add_password() {
1704 let (_temp_dir, archive_dir) = setup_test_archive();
1705
1706 let slot_id = key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1708 assert_eq!(slot_id, 1);
1709
1710 let result = key_list(&archive_dir).unwrap();
1712 assert_eq!(result.active_slots, 2);
1713
1714 let config = load_config(&archive_dir).unwrap();
1716 let dek = unwrap_dek_with_password(&config, "new-password").unwrap();
1717 assert!(!dek.iter().all(|&b| b == 0));
1718 }
1719
1720 #[test]
1721 fn test_key_add_recovery() {
1722 let (_temp_dir, archive_dir) = setup_test_archive();
1723
1724 let (slot_id, secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
1726 assert_eq!(slot_id, 1);
1727 assert_eq!(secret.entropy_bits(), 256);
1728
1729 let result = key_list(&archive_dir).unwrap();
1731 assert_eq!(result.active_slots, 2);
1732 assert_eq!(result.slots[1].slot_type, "recovery");
1733 assert_eq!(result.slots[1].kdf, "hkdf-sha256");
1734 }
1735
1736 #[test]
1737 fn test_key_add_wrong_password_fails() {
1738 let (_temp_dir, archive_dir) = setup_test_archive();
1739
1740 let result = key_add_password(&archive_dir, "wrong-password", "new-password");
1741 assert!(result.is_err());
1742 }
1743
1744 #[test]
1745 fn test_key_revoke() {
1746 let (_temp_dir, archive_dir) = setup_test_archive();
1747
1748 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1750
1751 let result = key_revoke(&archive_dir, "second-password", 0).unwrap();
1753 assert_eq!(result.revoked_slot_id, 0);
1754 assert_eq!(result.remaining_slots, 1);
1755
1756 let config = load_config(&archive_dir).unwrap();
1758 assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1759
1760 assert!(unwrap_dek_with_password(&config, "second-password").is_ok());
1762 }
1763
1764 #[test]
1765 fn test_key_revoke_last_slot_fails() {
1766 let (_temp_dir, archive_dir) = setup_test_archive();
1767
1768 let result = key_revoke(&archive_dir, "test-password", 0);
1769 assert!(result.is_err());
1770 assert!(result.unwrap_err().to_string().contains("last remaining"));
1771 }
1772
1773 #[test]
1774 fn test_key_revoke_auth_slot_fails() {
1775 let (_temp_dir, archive_dir) = setup_test_archive();
1776
1777 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1779
1780 let result = key_revoke(&archive_dir, "test-password", 0);
1782 assert!(result.is_err());
1783 assert!(result.unwrap_err().to_string().contains("authentication"));
1784 }
1785
1786 #[test]
1787 fn test_key_rotate() {
1788 let (temp_dir, archive_dir) = setup_test_archive();
1789 let decrypted_path = temp_dir.path().join("decrypted.txt");
1790
1791 let result =
1793 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
1794 assert_eq!(result.slot_count, 1);
1795 assert!(result.recovery_secret.is_none());
1796
1797 let config = load_config(&archive_dir).unwrap();
1799 assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1800
1801 let decryptor = DecryptionEngine::unlock_with_password(config, "new-password").unwrap();
1803 decryptor
1804 .decrypt_to_file(&archive_dir, &decrypted_path, |_, _| {})
1805 .unwrap();
1806
1807 let decrypted = std::fs::read(&decrypted_path).unwrap();
1808 assert_eq!(decrypted, b"Test data for key management");
1809 }
1810
1811 #[test]
1812 fn test_key_rotate_with_recovery() {
1813 let (_temp_dir, archive_dir) = setup_test_archive();
1814
1815 let result =
1817 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1818 assert_eq!(result.slot_count, 2);
1819 assert!(result.recovery_secret.is_some());
1820
1821 let list_result = key_list(&archive_dir).unwrap();
1823 assert_eq!(list_result.slots.len(), 2);
1824 assert_eq!(list_result.slots[0].slot_type, "password");
1825 assert_eq!(list_result.slots[1].slot_type, "recovery");
1826 }
1827
1828 #[test]
1829 fn test_key_add_after_revoke_no_id_collision() {
1830 let (_temp_dir, archive_dir) = setup_test_archive();
1831
1832 key_add_password(&archive_dir, "test-password", "password-1").unwrap();
1834 key_add_password(&archive_dir, "test-password", "password-2").unwrap();
1835
1836 let list = key_list(&archive_dir).unwrap();
1838 assert_eq!(list.slots.len(), 3);
1839
1840 key_revoke(&archive_dir, "password-2", 1).unwrap();
1842
1843 let list = key_list(&archive_dir).unwrap();
1845 assert_eq!(list.slots.len(), 2);
1846 let ids: Vec<u8> = list.slots.iter().map(|s| s.id).collect();
1847 assert_eq!(ids, vec![0, 2]);
1848
1849 let new_id = key_add_password(&archive_dir, "test-password", "password-3").unwrap();
1851 assert_eq!(new_id, 3, "New slot should get max_id + 1, not len()");
1852
1853 let config = load_config(&archive_dir).unwrap();
1855 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1856 assert!(unwrap_dek_with_password(&config, "password-1").is_err()); assert!(unwrap_dek_with_password(&config, "password-2").is_ok());
1858 assert!(unwrap_dek_with_password(&config, "password-3").is_ok());
1859 }
1860
1861 #[test]
1862 fn test_next_key_slot_id_rejects_max_id() {
1863 let (_temp_dir, archive_dir) = setup_test_archive();
1864 let mut config = load_config(&archive_dir).unwrap();
1865 config.key_slots[0].id = u8::MAX;
1866
1867 let err = next_key_slot_id(&config.key_slots).unwrap_err();
1868
1869 assert_eq!(
1870 err.to_string(),
1871 "Cannot add more key slots: maximum slot ID (255) reached"
1872 );
1873 }
1874
1875 #[test]
1876 fn test_key_add_password_preserves_valid_integrity_manifest() {
1877 let (_temp_dir, archive_dir) = setup_test_archive();
1878
1879 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1880
1881 key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1882
1883 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1884 }
1885
1886 #[test]
1887 fn test_key_rotate_preserves_valid_integrity_manifest() {
1888 let (_temp_dir, archive_dir) = setup_test_archive();
1889
1890 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1891
1892 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1893
1894 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1895 }
1896
1897 #[test]
1898 #[cfg(unix)]
1899 fn test_key_add_password_materializes_in_tree_symlinked_required_asset() -> Result<()> {
1900 let (_temp_dir, archive_dir) = setup_test_archive();
1901 let site_dir = super::super::resolve_site_dir(&archive_dir)?;
1902 replace_viewer_with_in_tree_symlink(&site_dir);
1903
1904 key_add_password(&archive_dir, "test-password", "new-password")?;
1905
1906 anyhow::ensure!(verify_bundle(&archive_dir, false)?.status == "valid");
1907 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js"))?;
1908 anyhow::ensure!(viewer_metadata.file_type().is_file());
1909 anyhow::ensure!(!viewer_metadata.file_type().is_symlink());
1910 Ok(())
1911 }
1912
1913 #[test]
1914 #[cfg(unix)]
1915 fn test_key_add_password_wrong_password_preserves_in_tree_symlinked_required_asset()
1916 -> Result<()> {
1917 let (_temp_dir, archive_dir) = setup_test_archive();
1918 let site_dir = super::super::resolve_site_dir(&archive_dir)?;
1919 replace_viewer_with_in_tree_symlink(&site_dir);
1920
1921 let err = match key_add_password(&archive_dir, "wrong-password", "new-password") {
1922 Ok(_) => bail!("wrong password unexpectedly added a key slot"),
1923 Err(err) => err,
1924 };
1925
1926 anyhow::ensure!(
1927 err.to_string().contains("Invalid password"),
1928 "unexpected error: {err:#}"
1929 );
1930 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js"))?;
1931 anyhow::ensure!(viewer_metadata.file_type().is_symlink());
1932 Ok(())
1933 }
1934
1935 #[test]
1936 #[cfg(unix)]
1937 fn test_key_rotate_materializes_in_tree_symlinked_required_asset() {
1938 let (_temp_dir, archive_dir) = setup_test_archive();
1939 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1940 replace_viewer_with_in_tree_symlink(&site_dir);
1941 let expected_viewer = std::fs::read(site_dir.join("viewer-real.js")).unwrap();
1942
1943 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1944
1945 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js")).unwrap();
1946 assert!(viewer_metadata.file_type().is_file());
1947 assert!(!viewer_metadata.file_type().is_symlink());
1948 assert_eq!(
1949 std::fs::read(site_dir.join("viewer.js")).unwrap(),
1950 expected_viewer
1951 );
1952 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1953 }
1954
1955 #[test]
1956 #[cfg(unix)]
1957 fn test_key_rotate_rejects_payload_directory_symlink_escape() {
1958 use std::os::unix::fs::symlink;
1959
1960 let (temp_dir, archive_dir) = setup_test_archive();
1961 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1962 let payload_dir = site_dir.join("payload");
1963 let outside_payload_dir = temp_dir.path().join("outside-payload");
1964
1965 std::fs::rename(&payload_dir, &outside_payload_dir).unwrap();
1966 symlink(&outside_payload_dir, &payload_dir).unwrap();
1967
1968 let err =
1969 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1970 assert!(
1971 err.to_string().contains("escapes archive directory"),
1972 "unexpected error: {err:#}"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_key_add_password_updates_private_fingerprint_and_master_key() {
1978 let (_temp_dir, archive_dir) = setup_test_archive();
1979 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1980 let private_dir = site_dir.parent().unwrap().join("private");
1981
1982 let old_fingerprint =
1983 std::fs::read_to_string(private_dir.join("integrity-fingerprint.txt")).unwrap();
1984 let old_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
1985
1986 key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1987
1988 let new_fingerprint =
1989 std::fs::read_to_string(private_dir.join("integrity-fingerprint.txt")).unwrap();
1990 let new_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
1991
1992 assert_ne!(old_fingerprint, new_fingerprint);
1993 assert_ne!(old_master_key, new_master_key);
1994 }
1995
1996 #[test]
1997 fn test_key_add_recovery_writes_private_recovery_artifact() {
1998 let (_temp_dir, archive_dir) = setup_test_archive();
1999 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2000 let private_dir = site_dir.parent().unwrap().join("private");
2001
2002 assert!(!private_dir.join("recovery-secret.txt").exists());
2003
2004 let (_slot_id, secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2005 let recovery_file =
2006 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2007
2008 assert!(recovery_file.contains(secret.encoded()));
2009 }
2010
2011 #[test]
2012 fn test_key_revoke_recovery_removes_private_recovery_artifact() {
2013 let (_temp_dir, archive_dir) = setup_test_archive();
2014 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2015 let private_dir = site_dir.parent().unwrap().join("private");
2016
2017 let (recovery_slot_id, _secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2018 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
2019 assert!(private_dir.join("recovery-secret.txt").exists());
2020
2021 key_revoke(&archive_dir, "second-password", recovery_slot_id).unwrap();
2022
2023 assert!(!private_dir.join("recovery-secret.txt").exists());
2024 }
2025
2026 #[test]
2027 fn test_key_revoke_one_of_multiple_recovery_slots_removes_stale_private_recovery_artifact() {
2028 let (_temp_dir, archive_dir) = setup_test_archive();
2029 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2030 let private_dir = site_dir.parent().unwrap().join("private");
2031
2032 let (first_recovery_slot_id, first_secret) =
2033 key_add_recovery(&archive_dir, "test-password").unwrap();
2034 let (second_recovery_slot_id, second_secret) =
2035 key_add_recovery(&archive_dir, "test-password").unwrap();
2036
2037 let recovery_file_before =
2038 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2039 assert!(recovery_file_before.contains(second_secret.encoded()));
2040
2041 key_revoke(&archive_dir, "test-password", second_recovery_slot_id).unwrap();
2042
2043 assert!(!private_dir.join("recovery-secret.txt").exists());
2044
2045 let config = load_config(&archive_dir).unwrap();
2046 assert!(DecryptionEngine::unlock_with_recovery(config, first_secret.as_bytes()).is_ok());
2047
2048 assert_ne!(first_recovery_slot_id, second_recovery_slot_id);
2049 }
2050
2051 #[test]
2052 fn test_key_rotate_refreshes_private_recovery_and_master_key() {
2053 let (_temp_dir, archive_dir) = setup_test_archive();
2054 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2055 let private_dir = site_dir.parent().unwrap().join("private");
2056
2057 let old_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2058 let result =
2059 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
2060
2061 let new_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2062 let recovery_file =
2063 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2064
2065 assert_ne!(old_master_key, new_master_key);
2066 assert!(recovery_file.contains(result.recovery_secret.as_deref().unwrap()));
2067 }
2068
2069 #[test]
2070 fn test_key_rotate_without_recovery_removes_stale_private_recovery_artifact() {
2071 let (_temp_dir, archive_dir) = setup_test_archive();
2072 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2073 let private_dir = site_dir.parent().unwrap().join("private");
2074
2075 let (_slot_id, _secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2076 assert!(private_dir.join("recovery-secret.txt").exists());
2077
2078 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
2079
2080 assert!(!private_dir.join("recovery-secret.txt").exists());
2081 assert!(!private_dir.join("qr-code.png").exists());
2082 assert!(!private_dir.join("qr-code.svg").exists());
2083 }
2084
2085 #[test]
2086 fn test_key_rotate_reencrypts_attachment_blobs() {
2087 let (_temp_dir, archive_dir) = setup_test_archive_with_attachments();
2088
2089 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
2090
2091 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
2092
2093 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2094 let config = load_config(&archive_dir).unwrap();
2095 let dek = unwrap_dek_with_password(&config, "new-password").unwrap();
2096 let export_id_raw = BASE64_STANDARD.decode(&config.export_id).unwrap();
2097 let export_id: [u8; 16] = export_id_raw.as_slice().try_into().unwrap();
2098
2099 let manifest_ciphertext =
2100 std::fs::read(site_dir.join("blobs").join("manifest.enc")).unwrap();
2101 let manifest = decrypt_manifest(&manifest_ciphertext, &dek, &export_id).unwrap();
2102 assert_eq!(manifest.entries.len(), 1);
2103 assert_eq!(manifest.entries[0].filename, "proof.txt");
2104
2105 let blob_ciphertext = std::fs::read(
2106 site_dir
2107 .join("blobs")
2108 .join(format!("{}.bin", manifest.entries[0].hash)),
2109 )
2110 .unwrap();
2111 let plaintext = decrypt_blob(
2112 &blob_ciphertext,
2113 &dek,
2114 &export_id,
2115 &manifest.entries[0].hash,
2116 )
2117 .unwrap();
2118 assert_eq!(plaintext, b"attachment payload");
2119 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
2120 }
2121
2122 #[test]
2123 fn test_key_rotate_failure_before_site_swap_preserves_live_archive() {
2124 let (temp_dir, archive_dir) = setup_test_archive_with_attachments();
2125 let decrypted_path = temp_dir.path().join("decrypted-after-failure.txt");
2126 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2127
2128 std::fs::write(site_dir.join("blobs").join("manifest.enc"), b"corrupted").unwrap();
2129
2130 let rotate_result =
2131 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {});
2132 assert!(rotate_result.is_err());
2133
2134 let config = load_config(&archive_dir).unwrap();
2135 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
2136
2137 let decryptor = DecryptionEngine::unlock_with_password(config, "test-password").unwrap();
2138 decryptor
2139 .decrypt_to_file(&archive_dir, &decrypted_path, |_, _| {})
2140 .unwrap();
2141
2142 let decrypted = std::fs::read(&decrypted_path).unwrap();
2143 assert_eq!(decrypted, b"Test data for key management");
2144 }
2145
2146 #[test]
2147 fn test_write_json_pretty_atomically_overwrites_existing_file() {
2148 let temp_dir = TempDir::new().unwrap();
2149 let path = temp_dir.path().join("config.json");
2150 std::fs::write(&path, "{\"before\":true}\n").unwrap();
2151
2152 let value = serde_json::json!({ "after": true });
2153 write_json_pretty_atomically(&path, &value).unwrap();
2154
2155 let written: serde_json::Value =
2156 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2157 assert_eq!(written, value);
2158 }
2159
2160 #[test]
2161 fn test_replace_dir_from_temp_overwrites_existing_site() {
2162 let temp_dir = TempDir::new().unwrap();
2163 let final_dir = temp_dir.path().join("archive");
2164 let staged_dir = temp_dir.path().join("archive.staged");
2165
2166 std::fs::create_dir_all(final_dir.join("site")).unwrap();
2167 std::fs::write(final_dir.join("site/old.txt"), "old").unwrap();
2168
2169 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2170 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2171
2172 replace_dir_from_temp(&staged_dir, &final_dir).unwrap();
2173
2174 assert!(!staged_dir.exists());
2175 assert!(final_dir.join("site/new.txt").exists());
2176 assert!(!final_dir.join("site/old.txt").exists());
2177 let sidecars = std::fs::read_dir(temp_dir.path())
2178 .unwrap()
2179 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2180 .collect::<Vec<_>>();
2181 assert!(
2182 !sidecars.iter().any(|name| name.contains(".archive.bak.")),
2183 "backup sidecar should be cleaned up, found: {sidecars:?}"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_replace_dir_from_temp_rejects_file_target() {
2189 let temp_dir = TempDir::new().unwrap();
2190 let final_dir = temp_dir.path().join("archive");
2191 let staged_dir = temp_dir.path().join("archive.staged");
2192
2193 std::fs::write(&final_dir, "not a directory").unwrap();
2194 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2195 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2196
2197 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
2198
2199 assert!(
2200 err.to_string().contains("not a directory"),
2201 "unexpected error: {err:#}"
2202 );
2203 assert!(staged_dir.exists());
2204 assert_eq!(
2205 std::fs::read_to_string(&final_dir).unwrap(),
2206 "not a directory"
2207 );
2208 }
2209
2210 #[test]
2211 #[cfg(unix)]
2212 fn test_replace_dir_from_temp_rejects_dangling_symlink_target() {
2213 use std::os::unix::fs::symlink;
2214
2215 let temp_dir = TempDir::new().unwrap();
2216 let final_dir = temp_dir.path().join("archive");
2217 let staged_dir = temp_dir.path().join("archive.staged");
2218 let missing_target = temp_dir.path().join("missing-archive");
2219
2220 symlink(&missing_target, &final_dir).unwrap();
2221 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2222 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2223
2224 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
2225
2226 assert!(
2227 err.to_string().contains("through symlink"),
2228 "unexpected error: {err:#}"
2229 );
2230 assert!(staged_dir.exists());
2231 assert!(
2232 std::fs::symlink_metadata(&final_dir)
2233 .unwrap()
2234 .file_type()
2235 .is_symlink()
2236 );
2237 }
2238
2239 #[test]
2256 fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2257 use aes_gcm::aead::{Aead, KeyInit, Payload};
2260 use aes_gcm::{Aes256Gcm, Nonce};
2261
2262 let kek = [0u8; 32];
2263 let dek = [0u8; 32];
2264 let export_id = [42u8; 16];
2265 let slot_id = 7u8;
2266 let nonce_bytes = [3u8; 12];
2267
2268 let mut aad = Vec::with_capacity(17);
2269 aad.extend_from_slice(&export_id);
2270 aad.push(slot_id);
2271
2272 let cipher = Aes256Gcm::new_from_slice(&kek).expect("Invalid key length");
2273 let mut wrapped = cipher
2274 .encrypt(
2275 Nonce::from_slice(&nonce_bytes),
2276 Payload {
2277 msg: &dek,
2278 aad: &aad,
2279 },
2280 )
2281 .expect("encrypt produces wrapped DEK + auth tag");
2282
2283 let last = wrapped.len() - 1;
2287 wrapped[last] ^= 0x55;
2288
2289 let err = unwrap_key(&kek, &wrapped, &nonce_bytes, &export_id, slot_id)
2290 .expect_err("tampered ciphertext must fail unwrap");
2291 let rendered = err.to_string();
2292
2293 assert!(
2295 rendered.contains(&format!("slot {slot_id}")),
2296 "unwrap error must name the slot id; got: {rendered}"
2297 );
2298 assert!(
2300 rendered.contains(&format!("{} bytes wrapped", wrapped.len())),
2301 "unwrap error must include the wrapped-ciphertext length; got: {rendered}"
2302 );
2303 assert!(
2304 rendered.contains("12 bytes nonce"),
2305 "unwrap error must include the AES-GCM nonce length; got: {rendered}"
2306 );
2307 assert!(
2309 rendered.contains(": "),
2310 "unwrap error must include `: <source>` separator so the \
2311 aead source error survives in the chain; got: {rendered}"
2312 );
2313 assert!(
2315 rendered.contains("Key unwrapping failed"),
2316 "unwrap error must keep the human-facing prefix for runbook \
2317 grep compatibility; got: {rendered}"
2318 );
2319 }
2320
2321 #[test]
2328 fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2329 let actual_kek = crate::encryption::hkdf_extract_expand(
2334 b"recovery-secret",
2335 b"salty-salty-salty-salt",
2336 b"cass-pages-kek-v2",
2337 16,
2338 )
2339 .expect("hkdf with 16-byte output must succeed");
2340 assert_eq!(actual_kek.len(), 16);
2341
2342 let conversion: Result<[u8; 32], Vec<u8>> = actual_kek.try_into();
2343 let raw_err = conversion.expect_err("16 != 32 must fail try_into");
2344 assert_eq!(raw_err.len(), 16);
2345
2346 let rendered = format!(
2350 "HKDF expansion produced invalid KEK length: expected 32, got {}",
2351 raw_err.len()
2352 );
2353 assert!(rendered.contains("expected 32"));
2354 assert!(rendered.contains("got 16"));
2355 }
2356}