Skip to main content

coding_agent_search/pages/
key_management.rs

1//! Key management operations for encrypted pages archives.
2//!
3//! Provides CLI operations to manage key slots in an encrypted archive:
4//! - `list`: Show all key slots
5//! - `add`: Add a new password or recovery key slot
6//! - `revoke`: Remove a key slot
7//! - `rotate`: Full key rotation (regenerate DEK, re-encrypt payload)
8//!
9//! # Security Model
10//!
11//! The archive uses envelope encryption with multiple key slots (like LUKS):
12//! - A random Data Encryption Key (DEK) encrypts the payload
13//! - Each key slot wraps the DEK with a Key Encryption Key (KEK)
14//! - KEK is derived from password (Argon2id) or recovery secret (HKDF-SHA256)
15//! - Add/revoke only modifies config.json; payload unchanged
16//! - Rotate re-encrypts entire payload with new DEK
17
18use 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/// Argon2id default parameters
41#[cfg(not(test))]
42const ARGON2_MEMORY_KB: u32 = 65536; // 64 MB
43#[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
54/// Schema version for encryption
55const 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/// Result of listing key slots
95#[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/// Information about a single key slot
104#[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/// Result of adding a key slot
116#[derive(Debug)]
117pub enum AddKeyResult {
118    Password { slot_id: u8 },
119    Recovery { slot_id: u8, secret: RecoverySecret },
120}
121
122/// Result of revoking a key slot
123#[derive(Debug, Serialize)]
124pub struct RevokeResult {
125    pub revoked_slot_id: u8,
126    pub remaining_slots: usize,
127}
128
129/// Result of key rotation
130#[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
138/// List all key slots in an archive
139pub 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, // Labels stored in encrypted metadata (future)
158        })
159        .collect();
160
161    Ok(KeyListResult {
162        active_slots: slots.len(),
163        slots,
164        dek_created_at: None, // Would need to store in config
165        export_id: config.export_id,
166    })
167}
168
169/// Add a new password-based key slot
170pub 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    // Unlock with current password to get DEK
181    let dek = zeroize::Zeroizing::new(unwrap_dek_with_password(&config, current_password)?);
182
183    // Create new slot (use max ID + 1 since IDs are stable after revocation)
184    // If no slots exist, start at 0; otherwise use max + 1
185    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 updated config
192    write_json_pretty_atomically(&config_path, &config)?;
193
194    // Update integrity.json if present
195    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
202/// Add a new recovery secret key slot
203pub 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    // Unlock with current password to get DEK
213    let dek = zeroize::Zeroizing::new(unwrap_dek_with_password(&config, current_password)?);
214
215    // Generate recovery secret
216    let secret = RecoverySecret::generate();
217
218    // Create new slot (use max ID + 1 since IDs are stable after revocation)
219    // If no slots exist, start at 0; otherwise use max + 1
220    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 updated config
227    write_json_pretty_atomically(&config_path, &config)?;
228
229    // Update integrity.json if present
230    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
252/// Revoke a key slot
253pub 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    // Safety: Cannot revoke last slot
264    if config.key_slots.len() <= 1 {
265        anyhow::bail!("Cannot revoke the last remaining key slot. Add another key first.");
266    }
267
268    // Find which slot authenticates with this password
269    let (auth_slot_id, dek) = unwrap_dek_with_slot_id(&config, current_password)?;
270    let mut _dek = zeroize::Zeroizing::new(dek); // Zeroize on drop
271
272    // Verify they aren't trying to revoke the slot they are currently using
273    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    // Verify slot exists
281    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    // Remove the slot (keeping IDs stable - they're part of the AAD binding)
295    config.key_slots.retain(|s| s.id != slot_id_to_revoke);
296
297    // Write updated config
298    write_json_pretty_atomically(&config_path, &config)?;
299
300    // Update integrity.json if present
301    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
321/// Full key rotation - regenerate DEK and re-encrypt payload
322pub 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        // [coding_agent_session_search-htiim] Chain the underlying
335        // TryFromSliceError so a debug-mode error chain shows the
336        // exact conversion that failed in addition to the
337        // human-readable length mismatch.
338        anyhow::anyhow!(
339            "invalid export_id length: expected 16, got {}: {err}",
340            old_export_id_raw.len()
341        )
342    })?;
343
344    // 1. Decrypt payload with old password
345    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    // 2. Generate new DEK and export_id
352    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    // 3. Re-encrypt payload with new DEK into the staged site
364    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    // 4. Create new key slots
384    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    // 5. Write new config
406    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, // Recalculated
416            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    // 6. Regenerate integrity.json for the staged site, then swap atomically
427    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
446// ============================================================================
447// Helper functions
448// ============================================================================
449
450/// Unwrap DEK using password (tries all password slots)
451fn 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
474/// Unwrap DEK and return which slot was used
475fn 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
498/// Derive KEK from password using Argon2id
499fn 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
518/// Derive KEK from recovery secret using HKDF-SHA256
519fn 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        // [coding_agent_session_search-htiim] Capture actual_len BEFORE
525        // try_into consumes the Vec so the message can show the actual
526        // KEK length operators / future contributors need to debug a
527        // frankensqlite / hkdf upstream regression.
528        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
536/// Unwrap DEK with KEK
537fn 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        // [coding_agent_session_search-htiim] Chain TryFromSliceError so
549        // debug-mode chains preserve the source while the operator
550        // sees the exact-length diagnostic.
551        anyhow::anyhow!(
552            "invalid nonce length: expected 12, got {}: {err}",
553            actual_nonce_len
554        )
555    })?;
556
557    // AAD: export_id || slot_id
558    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            // [coding_agent_session_search-htiim] Chain the underlying
572            // aead error so operators can distinguish "wrong password
573            // (KEK derivation succeeded but DEK MAC failed)" from
574            // "corrupt key slot ciphertext" from "wrong AAD (slot id /
575            // export id mismatch)". The aead crate's Display impl
576            // remains opaque about the specific sub-failure (timing-
577            // attack hardening), but the source error type IS preserved
578            // so debug-mode error chains can show whether the failure
579            // came from the cipher layer vs a subsequent layer. Slot
580            // id is included so operators can correlate with the
581            // recovery / password slot they were attempting. Mirrors
582            // the encrypt.rs::unwrap_key fix landed in 0b81b601.
583            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
603/// Create a password-based key slot
604fn 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    // Generate salt
613    let mut salt = [0u8; 32];
614    let mut rng = rand::rng();
615    rng.fill_bytes(&mut salt);
616
617    // Derive KEK from password
618    let kek = derive_kek_argon2id(password, &salt)?;
619
620    // Wrap DEK
621    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
636/// Create a recovery secret key slot
637fn 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    // Generate salt
646    let mut salt = [0u8; 16];
647    let mut rng = rand::rng();
648    rng.fill_bytes(&mut salt);
649
650    // Derive KEK from recovery secret
651    let kek = derive_kek_hkdf(secret, &salt)?;
652
653    // Wrap DEK
654    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
669/// Wrap DEK with KEK using AES-256-GCM
670fn 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    // AAD: export_id || slot_id
683    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
700/// Decrypt all chunks and return plaintext
701fn 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        // [coding_agent_session_search-htiim] Chain TryFromSliceError so
711        // debug chains preserve the source. Length diagnostic is the
712        // operator-facing signal.
713        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        // [coding_agent_session_search-htiim] Chain TryFromSliceError.
721        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        // Derive nonce
786        let nonce = derive_chunk_nonce(&base_nonce, chunk_index as u32);
787
788        // Build AAD
789        let aad = build_chunk_aad(&export_id, chunk_index as u32);
790
791        // Decrypt
792        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                // [coding_agent_session_search-htiim] Chain the aead error
802                // so operators can correlate: which chunk failed, how
803                // big the ciphertext was, and what the cipher layer
804                // reported. The aead crate keeps the sub-failure type
805                // opaque (timing-attack hardening) but the source is
806                // preserved in the error chain. Mirrors encrypt.rs::
807                // decrypt_all_chunks fix landed in 0b81b601.
808                anyhow::anyhow!(
809                    "Decryption failed for chunk {} ({} bytes ciphertext): {}",
810                    chunk_index,
811                    ciphertext.len(),
812                    err
813                )
814            })?;
815
816        // Decompress
817        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
828/// Encrypt plaintext into chunks
829fn 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        // Compress
853        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        // Derive nonce
861        let nonce = derive_chunk_nonce(base_nonce, chunk_index);
862
863        // Build AAD
864        let aad = build_chunk_aad(export_id, chunk_index);
865
866        // Encrypt
867        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        // Write chunk file
878        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
896/// Derive chunk nonce from base nonce and chunk index
897fn derive_chunk_nonce(base_nonce: &[u8; 12], chunk_index: u32) -> [u8; 12] {
898    let mut nonce = *base_nonce;
899    // Set the last 4 bytes to the chunk index (big-endian)
900    nonce[8..12].copy_from_slice(&chunk_index.to_be_bytes());
901    nonce
902}
903
904/// Build AAD for chunk encryption
905fn 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
913/// Regenerate entire integrity.json
914fn 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            // Materialize safe symlink targets into the staged site so the staged
1349            // integrity pass stays self-contained before the final atomic swap.
1350            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        // Create test file
1477        std::fs::write(&input_path, b"Test data for key management").unwrap();
1478
1479        // Encrypt
1480        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        // Add new password
1707        let slot_id = key_add_password(&archive_dir, "test-password", "new-password").unwrap();
1708        assert_eq!(slot_id, 1);
1709
1710        // Verify it was added
1711        let result = key_list(&archive_dir).unwrap();
1712        assert_eq!(result.active_slots, 2);
1713
1714        // Verify new password works
1715        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        // Add recovery slot
1725        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        // Verify it was added
1730        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        // Add second slot
1749        key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1750
1751        // Revoke first slot using second password
1752        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        // Old password should no longer work
1757        let config = load_config(&archive_dir).unwrap();
1758        assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1759
1760        // Second password should still work
1761        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        // Add second slot
1778        key_add_password(&archive_dir, "test-password", "second-password").unwrap();
1779
1780        // Try to revoke slot 0 using slot 0's password
1781        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        // Rotate keys
1792        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        // Old password should fail
1798        let config = load_config(&archive_dir).unwrap();
1799        assert!(unwrap_dek_with_password(&config, "test-password").is_err());
1800
1801        // New password should work and decrypt correctly
1802        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        // Rotate keys with recovery
1816        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        // Verify recovery slot
1822        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        // Add slots 1 and 2
1833        key_add_password(&archive_dir, "test-password", "password-1").unwrap();
1834        key_add_password(&archive_dir, "test-password", "password-2").unwrap();
1835
1836        // Now have slots [0, 1, 2]
1837        let list = key_list(&archive_dir).unwrap();
1838        assert_eq!(list.slots.len(), 3);
1839
1840        // Revoke slot 1 using slot 2's password
1841        key_revoke(&archive_dir, "password-2", 1).unwrap();
1842
1843        // Now have slots [0, 2] (gap at 1)
1844        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        // Add new slot - should get ID 3, not 2
1850        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        // Verify all passwords still work
1854        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()); // Revoked
1857        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    /// `coding_agent_session_search-htiim`: regression gate mirroring
2240    /// the unwrap_key contract pinned by `encrypt.rs::
2241    /// unwrap_key_chains_aead_source_error_into_diagnostic_message`
2242    /// (commit 0b81b601). Pre-fix, key_management.rs::unwrap_key
2243    /// returned bare "Key unwrapping failed" / "Invalid DEK length"
2244    /// strings that dropped the underlying aead::Error. Post-fix,
2245    /// every site preserves the source error in the chain AND
2246    /// surfaces actionable diagnostics (slot id, input lengths).
2247    /// This test exercises the unwrap_key path with a tampered
2248    /// ciphertext and asserts:
2249    ///   1. slot id appears in the rendered error
2250    ///   2. wrapped/nonce lengths appear (sanity-check of inputs)
2251    ///   3. ":" source-separator survives (a future refactor that
2252    ///      drops `: {err}` would fail this)
2253    ///   4. legacy "Key unwrapping failed" prefix preserved so
2254    ///      operator runbook grep patterns still match.
2255    #[test]
2256    fn unwrap_key_chains_aead_source_error_into_diagnostic_message() {
2257        // Build a real wrapped DEK directly with aes_gcm so we don't
2258        // depend on a higher-level encryption engine in this module.
2259        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        // Flip the last byte of the auth tag so MAC verification fails
2284        // on unwrap. AES-GCM appends a 16-byte auth tag — flipping
2285        // any byte in it is sufficient to fail verification.
2286        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        // Invariant 1: slot id present so operators can correlate.
2294        assert!(
2295            rendered.contains(&format!("slot {slot_id}")),
2296            "unwrap error must name the slot id; got: {rendered}"
2297        );
2298        // Invariant 2: input-size diagnostic survives.
2299        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        // Invariant 3: ":" source-separator survives.
2308        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        // Invariant 4: legacy prefix preserved for runbook grep.
2314        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    /// Companion gate for the HKDF KEK length-check arm. Pre-fix,
2322    /// `derive_kek_hkdf` returned bare "HKDF expansion produced
2323    /// invalid KEK length" with no diagnostic; post-fix, the message
2324    /// carries the actual length so operators can debug a
2325    /// frankensqlite / hkdf upstream regression that returned the
2326    /// wrong KEK size.
2327    #[test]
2328    fn derive_kek_hkdf_error_message_pins_actual_kek_length() {
2329        // Direct exercise of the conversion arm, using the public
2330        // hkdf wrapper to land at a 16-byte output (not 32). This
2331        // mirrors the gate landed in encrypt.rs by 0b81b601 so a
2332        // regression in either site fails its own assertion.
2333        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        // Codify the expected message shape so a future refactor
2347        // that reverts to `|_| ... "invalid KEK length"` without
2348        // actual_len fails the assertion.
2349        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}