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