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 replacement_path_entry_exists(final_path)? => {
1046 replace_file_from_temp_via_backup(temp_path, final_path, &first_err)
1047 }
1048 Err(err) => Err(err.into()),
1049 }
1050 } else {
1051 std::fs::rename(temp_path, final_path)?;
1052 sync_parent_directory(final_path)?;
1053 Ok(())
1054 }
1055}
1056
1057fn replacement_path_entry_exists(path: &Path) -> Result<bool> {
1058 match std::fs::symlink_metadata(path) {
1059 Ok(_) => Ok(true),
1060 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
1061 Err(err) => Err(err)
1062 .with_context(|| format!("failed inspecting replacement target {}", path.display())),
1063 }
1064}
1065
1066fn replace_file_from_temp_via_backup(
1067 temp_path: &Path,
1068 final_path: &Path,
1069 first_err: &std::io::Error,
1070) -> Result<()> {
1071 let backup_path = unique_atomic_backup_path(final_path);
1072 std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
1073 let _ = std::fs::remove_file(temp_path);
1074 anyhow::anyhow!(
1075 "failed replacing {} with {}: {}; failed moving existing file to backup {}: {}",
1076 final_path.display(),
1077 temp_path.display(),
1078 first_err,
1079 backup_path.display(),
1080 backup_err
1081 )
1082 })?;
1083
1084 match std::fs::rename(temp_path, final_path) {
1085 Ok(()) => {
1086 let _ = std::fs::remove_file(&backup_path);
1087 sync_parent_directory(final_path)?;
1088 Ok(())
1089 }
1090 Err(second_err) => match std::fs::rename(&backup_path, final_path) {
1091 Ok(()) => {
1092 let _ = std::fs::remove_file(temp_path);
1093 sync_parent_directory(final_path)?;
1094 anyhow::bail!(
1095 "failed replacing {} with {}: {}; restored original file",
1096 final_path.display(),
1097 temp_path.display(),
1098 second_err
1099 );
1100 }
1101 Err(restore_err) => {
1102 anyhow::bail!(
1103 "failed replacing {} with {}: {}; restore error: {}; temp file retained at {}",
1104 final_path.display(),
1105 temp_path.display(),
1106 second_err,
1107 restore_err,
1108 temp_path.display()
1109 );
1110 }
1111 },
1112 }
1113}
1114
1115#[cfg(not(windows))]
1116fn sync_parent_directory(path: &Path) -> Result<()> {
1117 let Some(parent) = path.parent() else {
1118 return Ok(());
1119 };
1120 std::fs::File::open(parent)?.sync_all()?;
1121 Ok(())
1122}
1123
1124#[cfg(windows)]
1125fn sync_parent_directory(_path: &Path) -> Result<()> {
1126 Ok(())
1127}
1128
1129fn unique_atomic_temp_path(path: &Path) -> std::path::PathBuf {
1130 unique_atomic_sidecar_path(path, "tmp", "config.json")
1131}
1132
1133fn unique_atomic_backup_path(path: &Path) -> std::path::PathBuf {
1134 unique_atomic_sidecar_path(path, "bak", "config.json")
1135}
1136
1137fn unique_atomic_sidecar_path(
1138 path: &Path,
1139 suffix: &str,
1140 fallback_name: &str,
1141) -> std::path::PathBuf {
1142 static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1143
1144 let timestamp = std::time::SystemTime::now()
1145 .duration_since(std::time::UNIX_EPOCH)
1146 .unwrap_or_default()
1147 .as_nanos();
1148 let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1149 let file_name = path
1150 .file_name()
1151 .and_then(|name| name.to_str())
1152 .unwrap_or(fallback_name);
1153
1154 path.with_file_name(format!(
1155 ".{file_name}.{suffix}.{}.{}.{}",
1156 std::process::id(),
1157 timestamp,
1158 nonce
1159 ))
1160}
1161
1162fn replace_dir_from_temp(temp_dir: &Path, final_dir: &Path) -> Result<()> {
1163 if !ensure_replaceable_site_dir(final_dir)? {
1164 std::fs::rename(temp_dir, final_dir).with_context(|| {
1165 format!(
1166 "failed renaming staged site {} into place at {}",
1167 temp_dir.display(),
1168 final_dir.display()
1169 )
1170 })?;
1171 sync_parent_directory(final_dir)?;
1172 return Ok(());
1173 }
1174
1175 let backup_dir = unique_atomic_sidecar_path(final_dir, "bak", "site");
1176 std::fs::rename(final_dir, &backup_dir).with_context(|| {
1177 format!(
1178 "failed preparing backup {} before replacing {}",
1179 backup_dir.display(),
1180 final_dir.display()
1181 )
1182 })?;
1183
1184 match std::fs::rename(temp_dir, final_dir) {
1185 Ok(()) => {
1186 sync_parent_directory(final_dir)?;
1187 let _ = std::fs::remove_dir_all(&backup_dir);
1188 sync_parent_directory(final_dir)?;
1189 Ok(())
1190 }
1191 Err(second_err) => match std::fs::rename(&backup_dir, final_dir) {
1192 Ok(()) => {
1193 let _ = std::fs::remove_dir_all(temp_dir);
1194 sync_parent_directory(final_dir)?;
1195 anyhow::bail!(
1196 "failed replacing {} with {}: {}; restored original site",
1197 final_dir.display(),
1198 temp_dir.display(),
1199 second_err
1200 )
1201 }
1202 Err(restore_err) => anyhow::bail!(
1203 "failed replacing {} with {}: {}; restore error: {}; staged site retained at {}",
1204 final_dir.display(),
1205 temp_dir.display(),
1206 second_err,
1207 restore_err,
1208 temp_dir.display()
1209 ),
1210 },
1211 }
1212}
1213
1214fn ensure_replaceable_site_dir(path: &Path) -> Result<bool> {
1215 match std::fs::symlink_metadata(path) {
1216 Ok(metadata) => {
1217 let file_type = metadata.file_type();
1218 if file_type.is_symlink() {
1219 bail!(
1220 "Refusing to replace site directory through symlink: {}",
1221 path.display()
1222 );
1223 }
1224 if !file_type.is_dir() {
1225 bail!(
1226 "Refusing to replace site directory because it is not a directory: {}",
1227 path.display()
1228 );
1229 }
1230 Ok(true)
1231 }
1232 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
1233 Err(err) => Err(err).with_context(|| {
1234 format!(
1235 "Failed inspecting site directory before replacement: {}",
1236 path.display()
1237 )
1238 }),
1239 }
1240}
1241
1242#[cfg(not(windows))]
1243fn sync_tree(path: &Path) -> Result<()> {
1244 sync_tree_inner(path)?;
1245 sync_parent_directory(path)
1246}
1247
1248#[cfg(windows)]
1249fn sync_tree(_path: &Path) -> Result<()> {
1250 Ok(())
1251}
1252
1253#[cfg(not(windows))]
1254fn sync_tree_inner(path: &Path) -> Result<()> {
1255 let metadata = std::fs::symlink_metadata(path)
1256 .with_context(|| format!("Failed reading metadata for {}", path.display()))?;
1257 let file_type = metadata.file_type();
1258 if file_type.is_symlink() {
1259 return Ok(());
1260 }
1261 if file_type.is_file() {
1262 std::fs::File::open(path)
1263 .with_context(|| format!("Failed opening {} for sync", path.display()))?
1264 .sync_all()
1265 .with_context(|| format!("Failed syncing {}", path.display()))?;
1266 return Ok(());
1267 }
1268 if file_type.is_dir() {
1269 for entry in std::fs::read_dir(path)
1270 .with_context(|| format!("Failed reading directory {}", path.display()))?
1271 {
1272 let entry = entry.with_context(|| format!("Failed walking {}", path.display()))?;
1273 sync_tree_inner(&entry.path())?;
1274 }
1275 std::fs::File::open(path)
1276 .with_context(|| format!("Failed opening directory {} for sync", path.display()))?
1277 .sync_all()
1278 .with_context(|| format!("Failed syncing directory {}", path.display()))?;
1279 }
1280 Ok(())
1281}
1282
1283fn copy_site_except_runtime_state(src: &Path, dst: &Path) -> Result<()> {
1284 std::fs::create_dir_all(dst)
1285 .with_context(|| format!("Failed to create staged site directory {}", dst.display()))?;
1286 let canonical_base = src.canonicalize().with_context(|| {
1287 format!(
1288 "Failed to resolve archive root {} before staging key rotation",
1289 src.display()
1290 )
1291 })?;
1292 copy_site_except_runtime_state_recursive(src, dst, src, &canonical_base)
1293}
1294
1295fn safe_staged_site_destination(dst_root: &Path, rel_path: &Path) -> Result<PathBuf> {
1296 let mut path_parts = vec![dst_root.to_path_buf()];
1297 for component in rel_path.components() {
1298 match component {
1299 Component::CurDir => {}
1300 Component::Normal(name) => path_parts.push(PathBuf::from(name)),
1301 _ => bail!(
1302 "Refusing to stage archive entry with unsafe relative path: {}",
1303 rel_path.display()
1304 ),
1305 }
1306 }
1307 Ok(path_parts.into_iter().collect())
1308}
1309
1310fn copy_site_except_runtime_state_recursive(
1311 src: &Path,
1312 dst: &Path,
1313 base: &Path,
1314 canonical_base: &Path,
1315) -> Result<()> {
1316 for entry in std::fs::read_dir(src)? {
1317 let entry = entry?;
1318 let path = entry.path();
1319 let rel_path = path.strip_prefix(base)?;
1320 let skip_root_entry = rel_path.components().count() == 1
1321 && matches!(
1322 rel_path.to_str(),
1323 Some("payload" | "blobs" | "config.json" | "integrity.json")
1324 );
1325 if skip_root_entry {
1326 continue;
1327 }
1328
1329 let metadata = std::fs::symlink_metadata(&path)?;
1330 let file_type = metadata.file_type();
1331 let dest_path = safe_staged_site_destination(dst, rel_path)?;
1332 if file_type.is_dir() {
1333 std::fs::create_dir_all(&dest_path)?;
1334 copy_site_except_runtime_state_recursive(&path, dst, base, canonical_base)?;
1335 } else if file_type.is_symlink() {
1336 let canonical_target = path.canonicalize().with_context(|| {
1337 format!(
1338 "Failed to resolve symlinked site entry {} while staging key rotation",
1339 rel_path.display()
1340 )
1341 })?;
1342 if !canonical_target.starts_with(canonical_base) {
1343 bail!(
1344 "Refusing to rotate symlinked site entry outside archive root: {}",
1345 rel_path.display()
1346 );
1347 }
1348
1349 let target_meta = std::fs::metadata(&path).with_context(|| {
1350 format!(
1351 "Failed to read symlink target metadata for {} while staging key rotation",
1352 rel_path.display()
1353 )
1354 })?;
1355 if !target_meta.is_file() {
1356 bail!(
1357 "Refusing to rotate symlinked site entry that does not point to a regular file: {}",
1358 rel_path.display()
1359 );
1360 }
1361
1362 if let Some(parent) = dest_path.parent() {
1363 std::fs::create_dir_all(parent)?;
1364 }
1365 std::fs::copy(&canonical_target, &dest_path).with_context(|| {
1368 format!(
1369 "Failed copying symlink target {} into staged site path {}",
1370 canonical_target.display(),
1371 dest_path.display()
1372 )
1373 })?;
1374 } else if file_type.is_file() {
1375 if let Some(parent) = dest_path.parent() {
1376 std::fs::create_dir_all(parent)?;
1377 }
1378 std::fs::copy(&path, &dest_path).with_context(|| {
1379 format!(
1380 "Failed copying staged site file {} to {}",
1381 path.display(),
1382 dest_path.display()
1383 )
1384 })?;
1385 }
1386 }
1387
1388 Ok(())
1389}
1390
1391fn refresh_private_artifacts(
1392 archive_dir: &Path,
1393 config: &EncryptionConfig,
1394 manifest: Option<&crate::pages::bundle::IntegrityManifest>,
1395 recovery_secret: Option<&[u8]>,
1396 remove_recovery_artifacts: bool,
1397) -> Result<()> {
1398 let Some(private_dir) = private_dir_for_archive(archive_dir)? else {
1399 return Ok(());
1400 };
1401
1402 if let Some(manifest) = manifest {
1403 let fingerprint = crate::pages::bundle::compute_fingerprint(manifest);
1404 crate::pages::bundle::write_private_fingerprint(&private_dir, &fingerprint)?;
1405 }
1406
1407 let should_generate_qr = recovery_secret.is_some()
1408 && (private_dir.join("qr-code.png").exists() || private_dir.join("qr-code.svg").exists());
1409
1410 crate::pages::bundle::write_private_artifacts_encrypted(
1411 &private_dir,
1412 config,
1413 recovery_secret,
1414 should_generate_qr,
1415 remove_recovery_artifacts,
1416 )?;
1417
1418 Ok(())
1419}
1420
1421fn private_dir_for_archive(archive_dir: &Path) -> Result<Option<std::path::PathBuf>> {
1422 if archive_dir
1423 .file_name()
1424 .map(|name| name == "site")
1425 .unwrap_or(false)
1426 {
1427 let Some(parent) = archive_dir.parent() else {
1428 return Ok(None);
1429 };
1430 let private_dir = parent.join("private");
1431 match std::fs::symlink_metadata(&private_dir) {
1432 Ok(metadata) => {
1433 let file_type = metadata.file_type();
1434 if file_type.is_symlink() {
1435 bail!(
1436 "private artifact directory must not be a symlink: {}",
1437 private_dir.display()
1438 );
1439 }
1440 if file_type.is_dir() {
1441 return Ok(Some(private_dir));
1442 }
1443 bail!(
1444 "private artifact path must be a directory: {}",
1445 private_dir.display()
1446 );
1447 }
1448 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1449 Err(err) => {
1450 return Err(err).with_context(|| {
1451 format!(
1452 "Failed to inspect private artifact directory {}",
1453 private_dir.display()
1454 )
1455 });
1456 }
1457 }
1458 }
1459
1460 Ok(None)
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465 use super::*;
1466 use crate::pages::attachments::{
1467 AttachmentConfig, AttachmentData, AttachmentProcessor, decrypt_blob, decrypt_manifest,
1468 };
1469 use crate::pages::bundle::BundleBuilder;
1470 use crate::pages::encrypt::{DecryptionEngine, EncryptionEngine, MAX_CHUNK_SIZE, PayloadMeta};
1471 use crate::pages::verify::verify_bundle;
1472 use std::cell::Cell;
1473 use tempfile::TempDir;
1474
1475 #[cfg(unix)]
1476 fn replace_viewer_with_in_tree_symlink(site_dir: &Path) {
1477 use std::os::unix::fs::symlink;
1478
1479 let real_viewer = site_dir.join("viewer-real.js");
1480 std::fs::rename(site_dir.join("viewer.js"), &real_viewer).unwrap();
1481 symlink("viewer-real.js", site_dir.join("viewer.js")).unwrap();
1482
1483 let manifest = crate::pages::bundle::generate_integrity_manifest(site_dir).unwrap();
1484 write_json_pretty(&site_dir.join("integrity.json"), &manifest).unwrap();
1485 }
1486
1487 fn setup_test_archive() -> (TempDir, std::path::PathBuf) {
1488 let temp_dir = TempDir::new().unwrap();
1489 let input_path = temp_dir.path().join("input.txt");
1490 let bundle_root = temp_dir.path().join("bundle");
1491 let encrypted_dir = temp_dir.path().join("encrypted");
1492
1493 std::fs::write(&input_path, b"Test data for key management").unwrap();
1495
1496 let mut engine = EncryptionEngine::new(1024).unwrap();
1498 engine.add_password_slot("test-password").unwrap();
1499 engine
1500 .encrypt_file(&input_path, &encrypted_dir, |_, _| {})
1501 .unwrap();
1502
1503 BundleBuilder::new()
1504 .build(&encrypted_dir, &bundle_root, |_, _| {})
1505 .unwrap();
1506
1507 (temp_dir, bundle_root)
1508 }
1509
1510 fn setup_test_archive_with_attachments() -> (TempDir, std::path::PathBuf) {
1511 let temp_dir = TempDir::new().unwrap();
1512 let input_path = temp_dir.path().join("input.txt");
1513 let bundle_root = temp_dir.path().join("bundle");
1514 let encrypted_dir = temp_dir.path().join("encrypted");
1515
1516 std::fs::write(&input_path, b"Test data for key management").unwrap();
1517
1518 let mut engine = EncryptionEngine::new(1024).unwrap();
1519 engine.add_password_slot("test-password").unwrap();
1520 engine
1521 .encrypt_file(&input_path, &encrypted_dir, |_, _| {})
1522 .unwrap();
1523
1524 let config = load_config(&encrypted_dir).unwrap();
1525 let dek = unwrap_dek_with_password(&config, "test-password").unwrap();
1526 let export_id_raw = BASE64_STANDARD.decode(&config.export_id).unwrap();
1527 let export_id: [u8; 16] = export_id_raw.as_slice().try_into().unwrap();
1528
1529 let mut processor = AttachmentProcessor::new(AttachmentConfig::enabled());
1530 processor
1531 .process_attachments(
1532 1,
1533 &[AttachmentData {
1534 filename: "proof.txt".to_string(),
1535 mime_type: "text/plain".to_string(),
1536 data: b"attachment payload".to_vec(),
1537 }],
1538 )
1539 .unwrap();
1540 processor
1541 .write_encrypted_blobs(&encrypted_dir, &dek, &export_id)
1542 .unwrap();
1543
1544 BundleBuilder::new()
1545 .build(&encrypted_dir, &bundle_root, |_, _| {})
1546 .unwrap();
1547
1548 (temp_dir, bundle_root)
1549 }
1550
1551 fn rewrite_test_config(archive_dir: &Path, mutate: impl FnOnce(&mut EncryptionConfig)) {
1552 let site_dir = super::super::resolve_site_dir(archive_dir).unwrap();
1553 let mut config = load_config(&site_dir).unwrap();
1554 mutate(&mut config);
1555 write_json_pretty(&site_dir.join("config.json"), &config).unwrap();
1556 }
1557
1558 fn assert_unsupported_payload_format_error(err: anyhow::Error, compression: &str) {
1559 let rendered = err.to_string();
1560 assert!(
1561 rendered.contains("supports only deflate") && rendered.contains(compression),
1562 "unexpected unsupported-format error: {err:#}"
1563 );
1564 }
1565
1566 #[test]
1567 #[cfg(unix)]
1568 fn test_private_dir_for_archive_rejects_symlinked_private_dir() {
1569 use std::os::unix::fs::symlink;
1570
1571 let temp = TempDir::new().unwrap();
1572 let site_dir = temp.path().join("bundle/site");
1573 let outside_private = temp.path().join("outside-private");
1574 std::fs::create_dir_all(&site_dir).unwrap();
1575 std::fs::create_dir_all(&outside_private).unwrap();
1576 symlink(&outside_private, temp.path().join("bundle/private")).unwrap();
1577
1578 let err = private_dir_for_archive(&site_dir).unwrap_err();
1579
1580 assert!(
1581 err.to_string().contains("must not be a symlink"),
1582 "unexpected error: {err:#}"
1583 );
1584 assert!(
1585 std::fs::symlink_metadata(temp.path().join("bundle/private"))
1586 .unwrap()
1587 .file_type()
1588 .is_symlink(),
1589 "rejected private directory symlink should remain untouched"
1590 );
1591 }
1592
1593 #[test]
1594 fn test_private_dir_for_archive_rejects_non_directory_private_path() {
1595 let temp = TempDir::new().unwrap();
1596 let site_dir = temp.path().join("bundle/site");
1597 std::fs::create_dir_all(&site_dir).unwrap();
1598 std::fs::write(temp.path().join("bundle/private"), "not a directory").unwrap();
1599
1600 let err = private_dir_for_archive(&site_dir).unwrap_err();
1601
1602 assert!(
1603 err.to_string().contains("must be a directory"),
1604 "unexpected error: {err:#}"
1605 );
1606 assert_eq!(
1607 std::fs::read_to_string(temp.path().join("bundle/private")).unwrap(),
1608 "not a directory"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_decrypt_all_chunks_rejects_mismatched_chunk_count_before_progress() {
1614 let temp_dir = TempDir::new().unwrap();
1615 let archive_dir = temp_dir.path();
1616 let config = EncryptionConfig {
1617 version: SCHEMA_VERSION,
1618 export_id: BASE64_STANDARD.encode([0u8; 16]),
1619 base_nonce: BASE64_STANDARD.encode([0u8; 12]),
1620 compression: "deflate".to_string(),
1621 kdf_defaults: Argon2Params::default(),
1622 payload: PayloadMeta {
1623 chunk_size: 1024,
1624 chunk_count: 0,
1625 total_compressed_size: 0,
1626 total_plaintext_size: 0,
1627 files: vec!["payload/chunk-00000.bin".to_string()],
1628 },
1629 key_slots: Vec::new(),
1630 };
1631 let progress_calls = Cell::new(0);
1632
1633 let err = decrypt_all_chunks(archive_dir, &[0u8; 32], &config, |progress| {
1634 assert!(progress.is_finite(), "progress must be finite: {progress}");
1635 progress_calls.set(progress_calls.get() + 1);
1636 })
1637 .unwrap_err();
1638
1639 assert!(
1640 err.to_string().contains("chunk_count 0"),
1641 "unexpected error: {err:#}"
1642 );
1643 assert_eq!(progress_calls.get(), 0);
1644 }
1645
1646 #[test]
1647 fn test_key_list() {
1648 let (_temp_dir, archive_dir) = setup_test_archive();
1649
1650 let result = key_list(&archive_dir).unwrap();
1651 assert_eq!(result.active_slots, 1);
1652 assert_eq!(result.slots.len(), 1);
1653 assert_eq!(result.slots[0].slot_type, "password");
1654 assert_eq!(result.slots[0].kdf, "argon2id");
1655 }
1656
1657 #[test]
1658 fn test_key_mutations_reject_unsupported_payload_compression() {
1659 let (_temp_dir, archive_dir) = setup_test_archive();
1660 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1661 rewrite_test_config(&archive_dir, |config| {
1662 config.compression = "zstd".to_string();
1663 });
1664
1665 let err = key_add_password(&archive_dir, "test-password", "third-password").unwrap_err();
1666 assert_unsupported_payload_format_error(err, "zstd");
1667
1668 let err = key_add_recovery(&archive_dir, "test-password").unwrap_err();
1669 assert_unsupported_payload_format_error(err, "zstd");
1670
1671 let err = key_revoke(&archive_dir, "second-password", 0).unwrap_err();
1672 assert_unsupported_payload_format_error(err, "zstd");
1673
1674 let err =
1675 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1676 assert_unsupported_payload_format_error(err, "zstd");
1677
1678 let config = load_config(&archive_dir).unwrap();
1679 assert_eq!(config.key_slots.len(), 2);
1680 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1681 assert!(unwrap_dek_with_password(&config, "second-password").is_ok());
1682 assert!(unwrap_dek_with_password(&config, "third-password").is_err());
1683 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
1684 }
1685
1686 #[test]
1687 fn test_key_rotate_rejects_oversized_payload_chunk_size_before_rewriting() {
1688 let (_temp_dir, archive_dir) = setup_test_archive();
1689 rewrite_test_config(&archive_dir, |config| {
1690 config.payload.chunk_size = MAX_CHUNK_SIZE + 1;
1691 });
1692
1693 let err =
1694 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1695 let rendered = err.to_string();
1696 assert!(
1697 rendered.contains("chunk_size") && rendered.contains("must be <="),
1698 "unexpected chunk-size error: {err:#}"
1699 );
1700
1701 let config = load_config(&archive_dir).unwrap();
1702 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1703 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
1704 }
1705
1706 #[test]
1707 fn test_key_rotate_chunk_count_preflight_preserves_nonce_space_limit() {
1708 ensure_archive_chunk_count_fits_nonce_space(u64::from(u32::MAX), 1).unwrap();
1709
1710 let err =
1711 ensure_archive_chunk_count_fits_nonce_space(u64::from(u32::MAX) + 1, 1).unwrap_err();
1712 let rendered = err.to_string();
1713 assert!(
1714 rendered.contains("exceeds maximum") && rendered.contains(&u32::MAX.to_string()),
1715 "unexpected chunk-count error: {rendered}"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_key_add_password() {
1721 let (_temp_dir, archive_dir) = setup_test_archive();
1722
1723 let slot_id = key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1725 assert_eq!(slot_id, 1);
1726
1727 let result = key_list(&archive_dir).unwrap();
1729 assert_eq!(result.active_slots, 2);
1730
1731 let config = load_config(&archive_dir).unwrap();
1733 let dek = unwrap_dek_with_password(&config, "new-password").unwrap();
1734 assert!(!dek.iter().all(|&b| b == 0));
1735 }
1736
1737 #[test]
1738 fn test_key_add_recovery() {
1739 let (_temp_dir, archive_dir) = setup_test_archive();
1740
1741 let (slot_id, secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
1743 assert_eq!(slot_id, 1);
1744 assert_eq!(secret.entropy_bits(), 256);
1745
1746 let result = key_list(&archive_dir).unwrap();
1748 assert_eq!(result.active_slots, 2);
1749 assert_eq!(result.slots[1].slot_type, "recovery");
1750 assert_eq!(result.slots[1].kdf, "hkdf-sha256");
1751 }
1752
1753 #[test]
1754 fn test_key_add_wrong_password_fails() {
1755 let (_temp_dir, archive_dir) = setup_test_archive();
1756
1757 let result = key_add_password(&archive_dir, "wrong-password", "new-password");
1758 assert!(result.is_err());
1759 }
1760
1761 #[test]
1762 fn test_key_revoke() {
1763 let (_temp_dir, archive_dir) = setup_test_archive();
1764
1765 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1767
1768 let result = key_revoke(&archive_dir, "second-password", 0).unwrap();
1770 assert_eq!(result.revoked_slot_id, 0);
1771 assert_eq!(result.remaining_slots, 1);
1772
1773 let config = load_config(&archive_dir).unwrap();
1775 assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1776
1777 assert!(unwrap_dek_with_password(&config, "second-password").is_ok());
1779 }
1780
1781 #[test]
1782 fn test_key_revoke_last_slot_fails() {
1783 let (_temp_dir, archive_dir) = setup_test_archive();
1784
1785 let result = key_revoke(&archive_dir, "test-password", 0);
1786 assert!(result.is_err());
1787 assert!(result.unwrap_err().to_string().contains("last remaining"));
1788 }
1789
1790 #[test]
1791 fn test_key_revoke_auth_slot_fails() {
1792 let (_temp_dir, archive_dir) = setup_test_archive();
1793
1794 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1796
1797 let result = key_revoke(&archive_dir, "test-password", 0);
1799 assert!(result.is_err());
1800 assert!(result.unwrap_err().to_string().contains("authentication"));
1801 }
1802
1803 #[test]
1804 fn test_key_rotate() {
1805 let (temp_dir, archive_dir) = setup_test_archive();
1806 let decrypted_path = temp_dir.path().join("decrypted.txt");
1807
1808 let result =
1810 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
1811 assert_eq!(result.slot_count, 1);
1812 assert!(result.recovery_secret.is_none());
1813
1814 let config = load_config(&archive_dir).unwrap();
1816 assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1817
1818 let decryptor = DecryptionEngine::unlock_with_password(config, "new-password").unwrap();
1820 decryptor
1821 .decrypt_to_file(&archive_dir, &decrypted_path, |_, _| {})
1822 .unwrap();
1823
1824 let decrypted = std::fs::read(&decrypted_path).unwrap();
1825 assert_eq!(decrypted, b"Test data for key management");
1826 }
1827
1828 #[test]
1829 fn test_key_rotate_with_recovery() {
1830 let (_temp_dir, archive_dir) = setup_test_archive();
1831
1832 let result =
1834 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1835 assert_eq!(result.slot_count, 2);
1836 assert!(result.recovery_secret.is_some());
1837
1838 let list_result = key_list(&archive_dir).unwrap();
1840 assert_eq!(list_result.slots.len(), 2);
1841 assert_eq!(list_result.slots[0].slot_type, "password");
1842 assert_eq!(list_result.slots[1].slot_type, "recovery");
1843 }
1844
1845 #[test]
1846 fn test_key_add_after_revoke_no_id_collision() {
1847 let (_temp_dir, archive_dir) = setup_test_archive();
1848
1849 key_add_password(&archive_dir, "test-password", "password-1").unwrap();
1851 key_add_password(&archive_dir, "test-password", "password-2").unwrap();
1852
1853 let list = key_list(&archive_dir).unwrap();
1855 assert_eq!(list.slots.len(), 3);
1856
1857 key_revoke(&archive_dir, "password-2", 1).unwrap();
1859
1860 let list = key_list(&archive_dir).unwrap();
1862 assert_eq!(list.slots.len(), 2);
1863 let ids: Vec<u8> = list.slots.iter().map(|s| s.id).collect();
1864 assert_eq!(ids, vec![0, 2]);
1865
1866 let new_id = key_add_password(&archive_dir, "test-password", "password-3").unwrap();
1868 assert_eq!(new_id, 3, "New slot should get max_id + 1, not len()");
1869
1870 let config = load_config(&archive_dir).unwrap();
1872 assert!(unwrap_dek_with_password(&config, "test-password").is_ok());
1873 assert!(unwrap_dek_with_password(&config, "password-1").is_err()); assert!(unwrap_dek_with_password(&config, "password-2").is_ok());
1875 assert!(unwrap_dek_with_password(&config, "password-3").is_ok());
1876 }
1877
1878 #[test]
1879 fn test_next_key_slot_id_rejects_max_id() {
1880 let (_temp_dir, archive_dir) = setup_test_archive();
1881 let mut config = load_config(&archive_dir).unwrap();
1882 config.key_slots[0].id = u8::MAX;
1883
1884 let err = next_key_slot_id(&config.key_slots).unwrap_err();
1885
1886 assert_eq!(
1887 err.to_string(),
1888 "Cannot add more key slots: maximum slot ID (255) reached"
1889 );
1890 }
1891
1892 #[test]
1893 fn test_key_add_password_preserves_valid_integrity_manifest() {
1894 let (_temp_dir, archive_dir) = setup_test_archive();
1895
1896 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1897
1898 key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1899
1900 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1901 }
1902
1903 #[test]
1904 fn test_key_rotate_preserves_valid_integrity_manifest() {
1905 let (_temp_dir, archive_dir) = setup_test_archive();
1906
1907 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1908
1909 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1910
1911 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1912 }
1913
1914 #[test]
1915 #[cfg(unix)]
1916 fn test_key_add_password_materializes_in_tree_symlinked_required_asset() -> 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 key_add_password(&archive_dir, "test-password", "new-password")?;
1922
1923 anyhow::ensure!(verify_bundle(&archive_dir, false)?.status == "valid");
1924 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js"))?;
1925 anyhow::ensure!(viewer_metadata.file_type().is_file());
1926 anyhow::ensure!(!viewer_metadata.file_type().is_symlink());
1927 Ok(())
1928 }
1929
1930 #[test]
1931 #[cfg(unix)]
1932 fn test_key_add_password_wrong_password_preserves_in_tree_symlinked_required_asset()
1933 -> Result<()> {
1934 let (_temp_dir, archive_dir) = setup_test_archive();
1935 let site_dir = super::super::resolve_site_dir(&archive_dir)?;
1936 replace_viewer_with_in_tree_symlink(&site_dir);
1937
1938 let err = match key_add_password(&archive_dir, "wrong-password", "new-password") {
1939 Ok(_) => bail!("wrong password unexpectedly added a key slot"),
1940 Err(err) => err,
1941 };
1942
1943 anyhow::ensure!(
1944 err.to_string().contains("Invalid password"),
1945 "unexpected error: {err:#}"
1946 );
1947 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js"))?;
1948 anyhow::ensure!(viewer_metadata.file_type().is_symlink());
1949 Ok(())
1950 }
1951
1952 #[test]
1953 #[cfg(unix)]
1954 fn test_key_rotate_materializes_in_tree_symlinked_required_asset() {
1955 let (_temp_dir, archive_dir) = setup_test_archive();
1956 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1957 replace_viewer_with_in_tree_symlink(&site_dir);
1958 let expected_viewer = std::fs::read(site_dir.join("viewer-real.js")).unwrap();
1959
1960 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
1961
1962 let viewer_metadata = std::fs::symlink_metadata(site_dir.join("viewer.js")).unwrap();
1963 assert!(viewer_metadata.file_type().is_file());
1964 assert!(!viewer_metadata.file_type().is_symlink());
1965 assert_eq!(
1966 std::fs::read(site_dir.join("viewer.js")).unwrap(),
1967 expected_viewer
1968 );
1969 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
1970 }
1971
1972 #[test]
1973 #[cfg(unix)]
1974 fn test_key_rotate_rejects_payload_directory_symlink_escape() {
1975 use std::os::unix::fs::symlink;
1976
1977 let (temp_dir, archive_dir) = setup_test_archive();
1978 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1979 let payload_dir = site_dir.join("payload");
1980 let outside_payload_dir = temp_dir.path().join("outside-payload");
1981
1982 std::fs::rename(&payload_dir, &outside_payload_dir).unwrap();
1983 symlink(&outside_payload_dir, &payload_dir).unwrap();
1984
1985 let err =
1986 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap_err();
1987 assert!(
1988 err.to_string().contains("escapes archive directory"),
1989 "unexpected error: {err:#}"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_key_add_password_updates_private_fingerprint_and_master_key() {
1995 let (_temp_dir, archive_dir) = setup_test_archive();
1996 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
1997 let private_dir = site_dir.parent().unwrap().join("private");
1998
1999 let old_fingerprint =
2000 std::fs::read_to_string(private_dir.join("integrity-fingerprint.txt")).unwrap();
2001 let old_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2002
2003 key_add_password(&archive_dir, "test-password", "new-password").unwrap();
2004
2005 let new_fingerprint =
2006 std::fs::read_to_string(private_dir.join("integrity-fingerprint.txt")).unwrap();
2007 let new_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2008
2009 assert_ne!(old_fingerprint, new_fingerprint);
2010 assert_ne!(old_master_key, new_master_key);
2011 }
2012
2013 #[test]
2014 fn test_key_add_recovery_writes_private_recovery_artifact() {
2015 let (_temp_dir, archive_dir) = setup_test_archive();
2016 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2017 let private_dir = site_dir.parent().unwrap().join("private");
2018
2019 assert!(!private_dir.join("recovery-secret.txt").exists());
2020
2021 let (_slot_id, secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2022 let recovery_file =
2023 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2024
2025 assert!(recovery_file.contains(secret.encoded()));
2026 }
2027
2028 #[test]
2029 fn test_key_revoke_recovery_removes_private_recovery_artifact() {
2030 let (_temp_dir, archive_dir) = setup_test_archive();
2031 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2032 let private_dir = site_dir.parent().unwrap().join("private");
2033
2034 let (recovery_slot_id, _secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2035 key_add_password(&archive_dir, "test-password", "second-password").unwrap();
2036 assert!(private_dir.join("recovery-secret.txt").exists());
2037
2038 key_revoke(&archive_dir, "second-password", recovery_slot_id).unwrap();
2039
2040 assert!(!private_dir.join("recovery-secret.txt").exists());
2041 }
2042
2043 #[test]
2044 fn test_key_revoke_one_of_multiple_recovery_slots_removes_stale_private_recovery_artifact() {
2045 let (_temp_dir, archive_dir) = setup_test_archive();
2046 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2047 let private_dir = site_dir.parent().unwrap().join("private");
2048
2049 let (first_recovery_slot_id, first_secret) =
2050 key_add_recovery(&archive_dir, "test-password").unwrap();
2051 let (second_recovery_slot_id, second_secret) =
2052 key_add_recovery(&archive_dir, "test-password").unwrap();
2053
2054 let recovery_file_before =
2055 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2056 assert!(recovery_file_before.contains(second_secret.encoded()));
2057
2058 key_revoke(&archive_dir, "test-password", second_recovery_slot_id).unwrap();
2059
2060 assert!(!private_dir.join("recovery-secret.txt").exists());
2061
2062 let config = load_config(&archive_dir).unwrap();
2063 assert!(DecryptionEngine::unlock_with_recovery(config, first_secret.as_bytes()).is_ok());
2064
2065 assert_ne!(first_recovery_slot_id, second_recovery_slot_id);
2066 }
2067
2068 #[test]
2069 fn test_key_rotate_refreshes_private_recovery_and_master_key() {
2070 let (_temp_dir, archive_dir) = setup_test_archive();
2071 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2072 let private_dir = site_dir.parent().unwrap().join("private");
2073
2074 let old_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2075 let result =
2076 key_rotate(&archive_dir, "test-password", "new-password", true, |_| {}).unwrap();
2077
2078 let new_master_key = std::fs::read_to_string(private_dir.join("master-key.json")).unwrap();
2079 let recovery_file =
2080 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
2081
2082 assert_ne!(old_master_key, new_master_key);
2083 assert!(recovery_file.contains(result.recovery_secret.as_deref().unwrap()));
2084 }
2085
2086 #[test]
2087 fn test_key_rotate_without_recovery_removes_stale_private_recovery_artifact() {
2088 let (_temp_dir, archive_dir) = setup_test_archive();
2089 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2090 let private_dir = site_dir.parent().unwrap().join("private");
2091
2092 let (_slot_id, _secret) = key_add_recovery(&archive_dir, "test-password").unwrap();
2093 assert!(private_dir.join("recovery-secret.txt").exists());
2094
2095 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
2096
2097 assert!(!private_dir.join("recovery-secret.txt").exists());
2098 assert!(!private_dir.join("qr-code.png").exists());
2099 assert!(!private_dir.join("qr-code.svg").exists());
2100 }
2101
2102 #[test]
2103 fn test_key_rotate_reencrypts_attachment_blobs() {
2104 let (_temp_dir, archive_dir) = setup_test_archive_with_attachments();
2105
2106 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
2107
2108 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {}).unwrap();
2109
2110 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2111 let config = load_config(&archive_dir).unwrap();
2112 let dek = unwrap_dek_with_password(&config, "new-password").unwrap();
2113 let export_id_raw = BASE64_STANDARD.decode(&config.export_id).unwrap();
2114 let export_id: [u8; 16] = export_id_raw.as_slice().try_into().unwrap();
2115
2116 let manifest_ciphertext =
2117 std::fs::read(site_dir.join("blobs").join("manifest.enc")).unwrap();
2118 let manifest = decrypt_manifest(&manifest_ciphertext, &dek, &export_id).unwrap();
2119 assert_eq!(manifest.entries.len(), 1);
2120 assert_eq!(manifest.entries[0].filename, "proof.txt");
2121
2122 let blob_ciphertext = std::fs::read(
2123 site_dir
2124 .join("blobs")
2125 .join(format!("{}.bin", manifest.entries[0].hash)),
2126 )
2127 .unwrap();
2128 let plaintext = decrypt_blob(
2129 &blob_ciphertext,
2130 &dek,
2131 &export_id,
2132 &manifest.entries[0].hash,
2133 )
2134 .unwrap();
2135 assert_eq!(plaintext, b"attachment payload");
2136 assert_eq!(verify_bundle(&archive_dir, false).unwrap().status, "valid");
2137 }
2138
2139 #[test]
2140 fn test_key_rotate_failure_before_site_swap_preserves_live_archive() {
2141 let (temp_dir, archive_dir) = setup_test_archive_with_attachments();
2142 let decrypted_path = temp_dir.path().join("decrypted-after-failure.txt");
2143 let site_dir = super::super::resolve_site_dir(&archive_dir).unwrap();
2144
2145 std::fs::write(site_dir.join("blobs").join("manifest.enc"), b"corrupted").unwrap();
2146
2147 let rotate_result =
2148 key_rotate(&archive_dir, "test-password", "new-password", false, |_| {});
2149 assert!(rotate_result.is_err());
2150
2151 let config = load_config(&archive_dir).unwrap();
2152 assert!(unwrap_dek_with_password(&config, "new-password").is_err());
2153
2154 let decryptor = DecryptionEngine::unlock_with_password(config, "test-password").unwrap();
2155 decryptor
2156 .decrypt_to_file(&archive_dir, &decrypted_path, |_, _| {})
2157 .unwrap();
2158
2159 let decrypted = std::fs::read(&decrypted_path).unwrap();
2160 assert_eq!(decrypted, b"Test data for key management");
2161 }
2162
2163 #[test]
2164 fn test_write_json_pretty_atomically_overwrites_existing_file() {
2165 let temp_dir = TempDir::new().unwrap();
2166 let path = temp_dir.path().join("config.json");
2167 std::fs::write(&path, "{\"before\":true}\n").unwrap();
2168
2169 let value = serde_json::json!({ "after": true });
2170 write_json_pretty_atomically(&path, &value).unwrap();
2171
2172 let written: serde_json::Value =
2173 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2174 assert_eq!(written, value);
2175 }
2176
2177 #[test]
2178 fn test_unique_atomic_backup_path_changes_each_call() -> Result<()> {
2179 use std::collections::BTreeSet;
2180
2181 let temp_dir = TempDir::new()?;
2182 let path = temp_dir.path().join("config.json");
2183
2184 let first = unique_atomic_backup_path(&path);
2185 let second = unique_atomic_backup_path(&path);
2186 let mut seen = BTreeSet::new();
2187
2188 if !seen.insert(first.clone()) || !seen.insert(second.clone()) {
2189 anyhow::bail!(
2190 "backup sidecar names should be unique, got {} twice",
2191 first.display()
2192 );
2193 }
2194 Ok(())
2195 }
2196
2197 #[cfg(unix)]
2198 #[test]
2199 fn test_replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
2200 use std::os::unix::fs::symlink;
2201
2202 let temp_dir = TempDir::new()?;
2203 let link_path = temp_dir.path().join("config.json");
2204 let missing_target = temp_dir.path().join("missing-config.json");
2205
2206 symlink(&missing_target, &link_path)?;
2207
2208 if link_path.exists() {
2209 anyhow::bail!("dangling symlink unexpectedly resolved through Path::exists");
2210 }
2211 if !replacement_path_entry_exists(&link_path)? {
2212 anyhow::bail!(
2213 "replacement path entry check missed dangling symlink {}",
2214 link_path.display()
2215 );
2216 }
2217 Ok(())
2218 }
2219
2220 #[test]
2221 fn test_replace_file_from_temp_via_backup_overwrites_existing_file() -> Result<()> {
2222 let temp_dir = TempDir::new()?;
2223 let final_path = temp_dir.path().join("config.json");
2224 let temp_path = temp_dir.path().join("config.tmp");
2225 let first_err = std::io::Error::from(std::io::ErrorKind::AlreadyExists);
2226
2227 std::fs::write(&final_path, br#"{"slots":["old"]}"#)?;
2228 std::fs::write(&temp_path, br#"{"slots":["new"]}"#)?;
2229
2230 replace_file_from_temp_via_backup(&temp_path, &final_path, &first_err)?;
2231
2232 let content = std::fs::read_to_string(&final_path)?;
2233 if !content.contains("new") {
2234 anyhow::bail!("final config did not contain replacement content: {content}");
2235 }
2236 if temp_path.exists() {
2237 anyhow::bail!(
2238 "temporary config was left behind at {}",
2239 temp_path.display()
2240 );
2241 }
2242 Ok(())
2243 }
2244
2245 #[test]
2246 fn test_replace_dir_from_temp_overwrites_existing_site() {
2247 let temp_dir = TempDir::new().unwrap();
2248 let final_dir = temp_dir.path().join("archive");
2249 let staged_dir = temp_dir.path().join("archive.staged");
2250
2251 std::fs::create_dir_all(final_dir.join("site")).unwrap();
2252 std::fs::write(final_dir.join("site/old.txt"), "old").unwrap();
2253
2254 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2255 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2256
2257 replace_dir_from_temp(&staged_dir, &final_dir).unwrap();
2258
2259 assert!(!staged_dir.exists());
2260 assert!(final_dir.join("site/new.txt").exists());
2261 assert!(!final_dir.join("site/old.txt").exists());
2262 let sidecars = std::fs::read_dir(temp_dir.path())
2263 .unwrap()
2264 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2265 .collect::<Vec<_>>();
2266 assert!(
2267 !sidecars.iter().any(|name| name.contains(".archive.bak.")),
2268 "backup sidecar should be cleaned up, found: {sidecars:?}"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_replace_dir_from_temp_rejects_file_target() {
2274 let temp_dir = TempDir::new().unwrap();
2275 let final_dir = temp_dir.path().join("archive");
2276 let staged_dir = temp_dir.path().join("archive.staged");
2277
2278 std::fs::write(&final_dir, "not a directory").unwrap();
2279 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2280 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2281
2282 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
2283
2284 assert!(
2285 err.to_string().contains("not a directory"),
2286 "unexpected error: {err:#}"
2287 );
2288 assert!(staged_dir.exists());
2289 assert_eq!(
2290 std::fs::read_to_string(&final_dir).unwrap(),
2291 "not a directory"
2292 );
2293 }
2294
2295 #[test]
2296 #[cfg(unix)]
2297 fn test_replace_dir_from_temp_rejects_dangling_symlink_target() {
2298 use std::os::unix::fs::symlink;
2299
2300 let temp_dir = TempDir::new().unwrap();
2301 let final_dir = temp_dir.path().join("archive");
2302 let staged_dir = temp_dir.path().join("archive.staged");
2303 let missing_target = temp_dir.path().join("missing-archive");
2304
2305 symlink(&missing_target, &final_dir).unwrap();
2306 std::fs::create_dir_all(staged_dir.join("site")).unwrap();
2307 std::fs::write(staged_dir.join("site/new.txt"), "new").unwrap();
2308
2309 let err = replace_dir_from_temp(&staged_dir, &final_dir).unwrap_err();
2310
2311 assert!(
2312 err.to_string().contains("through symlink"),
2313 "unexpected error: {err:#}"
2314 );
2315 assert!(staged_dir.exists());
2316 assert!(
2317 std::fs::symlink_metadata(&final_dir)
2318 .unwrap()
2319 .file_type()
2320 .is_symlink()
2321 );
2322 }
2323
2324 #[test]
2341 fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2342 use aes_gcm::aead::{Aead, KeyInit, Payload};
2345 use aes_gcm::{Aes256Gcm, Nonce};
2346
2347 let kek = [0u8; 32];
2348 let dek = [0u8; 32];
2349 let export_id = [42u8; 16];
2350 let slot_id = 7u8;
2351 let nonce_bytes = [3u8; 12];
2352
2353 let mut aad = Vec::with_capacity(17);
2354 aad.extend_from_slice(&export_id);
2355 aad.push(slot_id);
2356
2357 let cipher = Aes256Gcm::new_from_slice(&kek).expect("Invalid key length");
2358 let mut wrapped = cipher
2359 .encrypt(
2360 Nonce::from_slice(&nonce_bytes),
2361 Payload {
2362 msg: &dek,
2363 aad: &aad,
2364 },
2365 )
2366 .expect("encrypt produces wrapped DEK + auth tag");
2367
2368 let last = wrapped.len() - 1;
2372 wrapped[last] ^= 0x55;
2373
2374 let err = unwrap_key(&kek, &wrapped, &nonce_bytes, &export_id, slot_id)
2375 .expect_err("tampered ciphertext must fail unwrap");
2376 let rendered = err.to_string();
2377
2378 assert!(
2380 rendered.contains(&format!("slot {slot_id}")),
2381 "unwrap error must name the slot id; got: {rendered}"
2382 );
2383 assert!(
2385 rendered.contains(&format!("{} bytes wrapped", wrapped.len())),
2386 "unwrap error must include the wrapped-ciphertext length; got: {rendered}"
2387 );
2388 assert!(
2389 rendered.contains("12 bytes nonce"),
2390 "unwrap error must include the AES-GCM nonce length; got: {rendered}"
2391 );
2392 assert!(
2394 rendered.contains(": "),
2395 "unwrap error must include `: <source>` separator so the \
2396 aead source error survives in the chain; got: {rendered}"
2397 );
2398 assert!(
2400 rendered.contains("Key unwrapping failed"),
2401 "unwrap error must keep the human-facing prefix for runbook \
2402 grep compatibility; got: {rendered}"
2403 );
2404 }
2405
2406 #[test]
2413 fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2414 let actual_kek = crate::encryption::hkdf_extract_expand(
2419 b"recovery-secret",
2420 b"salty-salty-salty-salt",
2421 b"cass-pages-kek-v2",
2422 16,
2423 )
2424 .expect("hkdf with 16-byte output must succeed");
2425 assert_eq!(actual_kek.len(), 16);
2426
2427 let conversion: Result<[u8; 32], Vec<u8>> = actual_kek.try_into();
2428 let raw_err = conversion.expect_err("16 != 32 must fail try_into");
2429 assert_eq!(raw_err.len(), 16);
2430
2431 let rendered = format!(
2435 "HKDF expansion produced invalid KEK length: expected 32, got {}",
2436 raw_err.len()
2437 );
2438 assert!(rendered.contains("expected 32"));
2439 assert!(rendered.contains("got 16"));
2440 }
2441}