Skip to main content

qualia_client_core/wellfair/
sanctuary_vault.rs

1//! Encrypted-at-rest Sanctuary store with an independent decoy lane (master plan §6).
2//!
3//! Unlike the projection filter in [`super::sanctuary`] (which merely hides journal rows on
4//! read), this is a **real boundary**: sensitive notes live only inside AEAD-encrypted lanes,
5//! keyed by material derived from the owner's PIN with **Argon2id** (memory-hard; ADR D1) — or
6//! PBKDF2-HMAC-SHA256 for a PBKDF2-configured vault — over a per-lane random salt. When the vault
7//! is not unlocked there is nothing readable on disk — not a filtered view, actual ciphertext.
8//!
9//! The on-disk format is the **CBOR-native, n-layer** [`VaultContainerV2`] (vault v2, ADR §2/§9).
10//! There is **no JSON path** anywhere in this module: the container and the per-lane records are
11//! both `ciborium`-encoded. The container carries a constant number of layer slots (padded with
12//! reserved layers) so the on-disk layer *count* reveals nothing about how many lanes are real /
13//! decoy / empty.
14//!
15//! Two independent lanes are in use:
16//! - **Real** — the true Sanctuary, opened by the real PIN.
17//! - **Decoy** — a separate encrypted lane with its own salt/key, opened by the duress PIN. It
18//!   never aliases real data (different key, different ciphertext) and a duress unlock only ever
19//!   touches the decoy lane.
20//!
21//! The PIN is never stored, not even hashed. A per-lane *verifier* (a fixed magic string encrypted
22//! under the lane key) is used to recognise which lane a PIN belongs to. There is no destructive
23//! "nuke PIN" (plan §6).
24//!
25//! Native-only: `qualia_core_db::crypto::sanctuary_crypto` is `not(wasm32)`; the desktop is the
26//! authoritative node that owns keys and the vault.
27#![cfg(not(target_arch = "wasm32"))]
28
29use std::fs;
30use std::path::{Path, PathBuf};
31
32use serde::de::DeserializeOwned;
33use serde::{Deserialize, Serialize};
34
35use qualia_core_db::crypto::sanctuary_audit::{
36    open_sealed, seal_to, unwrap_key, wrap_key, AuditKeypair, GENESIS_PARENT,
37};
38use qualia_core_db::crypto::sanctuary_audit_dag::{
39    derive_sessions, verify_chain, AuditAction, AuditRecord, ChainStatus, RetentionMode,
40};
41use qualia_core_db::crypto::sanctuary_crypto::{
42    decrypt_sanctuary_chunk, derive_sanctuary_key_material, derive_sanctuary_key_material_argon2,
43    encrypt_sanctuary_chunk, SanctuaryAeadAlgorithm, SanctuaryKeyMaterial, ARGON2_M_COST_KIB,
44    ARGON2_P_COST, ARGON2_T_COST, SANCTUARY_TAG_BYTES,
45};
46use qualia_core_db::crypto::sanctuary_keychain;
47use sha2::{Digest, Sha256};
48
49use super::vault_container::{
50    EncBlob, KdfDescriptor, Layer, LayerRole, VaultContainerV2, WrappedKey,
51};
52
53/// On-disk vault file. **CBOR**, not JSON (vault v2).
54pub const SANCTUARY_VAULT_FILE: &str = "wellfair/sanctuary_vault.cbor";
55const ALGO: SanctuaryAeadAlgorithm = SanctuaryAeadAlgorithm::Aes256Gcm;
56const VERIFIER_MAGIC: &[u8] = b"WELLFAIR-SANCTUARY-VERIFIER-v1";
57/// Reserved chunk index for the verifier so it never shares a nonce with a records write.
58const VERIFIER_CHUNK: u64 = u64::MAX;
59/// Minimum PIN/passphrase length (ADR D5 — raised from 4). There is no maximum: a passphrase is
60/// encouraged, and Argon2id makes a long secret cheap to stretch.
61const MIN_PIN_LEN: usize = 6;
62
63// --- Real→decoy key hierarchy (ADR §3.2 / §7 crypto note) ---
64//
65// At setup the real lane's *wrapping key* wraps (a) the decoy lane's 48-byte key material and (b)
66// the audit secret. A real session can therefore reach *down* into the decoy (curate it) and read
67// the audit log, without re-entering the decoy PIN — but the decoy lane holds nothing that unwraps
68// toward real, so the hierarchy is strictly one-way.
69/// Purpose tag for the wrapped decoy lane key stored on the real layer.
70const WK_DECOY_LANE_KEY: &str = "decoy_lane_key";
71/// Purpose tag for the wrapped audit secret stored on the real layer.
72const WK_AUDIT_SECRET: &str = "audit_secret";
73/// AAD binding the decoy-lane-key wrap to its purpose.
74const WRAP_AAD_DECOY: &[u8] = b"q42:sanctuary:wrap:decoy-lane-key:v1";
75/// AAD binding the audit-secret wrap to its purpose.
76const WRAP_AAD_AUDIT: &[u8] = b"q42:sanctuary:wrap:audit-secret:v1";
77/// Serialized length of `SanctuaryKeyMaterial` (32-byte cipher key ‖ 16-byte volume tweak).
78const KEY_MATERIAL_BYTES: usize = 48;
79
80// --- Blind audit channel (ADR §3.1 / §10) ---
81/// Asserted actor DID for a decoy (duress) session. Unauthenticated by design: a coercer operates
82/// as a natural person under the decoy-layer DID; the real lane treats it as "decoy session".
83const DECOY_ACTOR_DID: &str = "did:qualia:sanctuary:decoy-session";
84/// The branch a plain (non-session-aware) decoy write lands on. The session-aware
85/// [`add_note_in_session`] opens a fresh branch per duress unlock; the plain [`add_note`] fallback
86/// accumulates on this single branch (S6's host layer supplies real per-session refs).
87const DEFAULT_DECOY_BRANCH: &str = "decoy:default";
88
89/// Reject the weakest PINs a coercer or shoulder-surfer would try first (ADR D5). This is a floor,
90/// not a strength meter — it blocks trivially guessable values, it does not certify strong ones.
91fn validate_pin_strength(pin: &str) -> Result<(), String> {
92    if pin.chars().count() < MIN_PIN_LEN {
93        return Err(format!(
94            "PIN/passphrase must be at least {MIN_PIN_LEN} characters"
95        ));
96    }
97    if let Some(first) = pin.chars().next() {
98        if pin.chars().all(|c| c == first) {
99            return Err("Too weak: all identical characters".into());
100        }
101    }
102    if is_trivial_sequence(pin) {
103        return Err("Too weak: a straight run of consecutive characters".into());
104    }
105    const COMMON: &[&str] = &[
106        "123456", "1234567", "12345678", "password", "qwerty", "111111", "000000", "letmein",
107        "abc123", "iloveyou",
108    ];
109    if COMMON.iter().any(|c| c.eq_ignore_ascii_case(pin)) {
110        return Err("Too common — choose something less guessable".into());
111    }
112    Ok(())
113}
114
115/// True for a straight ascending/descending run over the whole string (e.g. `123456`, `987654`,
116/// `abcdef`). A real passphrase will not be a single monotone run.
117fn is_trivial_sequence(pin: &str) -> bool {
118    let b = pin.as_bytes();
119    if b.len() < MIN_PIN_LEN {
120        return false;
121    }
122    let ascending = b.windows(2).all(|w| w[1] == w[0].wrapping_add(1));
123    let descending = b.windows(2).all(|w| w[0] == w[1].wrapping_add(1));
124    ascending || descending
125}
126
127/// Which lane a PIN opened.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SanctuaryLane {
131    Real,
132    Decoy,
133}
134
135impl SanctuaryLane {
136    fn aad(self) -> &'static [u8] {
137        match self {
138            SanctuaryLane::Real => b"wellfair:sanctuary:real",
139            SanctuaryLane::Decoy => b"wellfair:sanctuary:decoy",
140        }
141    }
142
143    /// The container layer role this lane maps to.
144    fn role(self) -> LayerRole {
145        match self {
146            SanctuaryLane::Real => LayerRole::Real,
147            SanctuaryLane::Decoy => LayerRole::Decoy,
148        }
149    }
150
151    /// Stable, non-secret layer id used when the lane is first created.
152    fn layer_id(self) -> &'static str {
153        match self {
154            SanctuaryLane::Real => "real",
155            SanctuaryLane::Decoy => "decoy:0",
156        }
157    }
158}
159
160/// A sensitive note held only inside the encrypted vault.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SanctuaryVaultNote {
163    pub id: String,
164    pub body: String,
165    pub created_at_unix: u32,
166}
167
168/// A witnessed head anchor for one audit branch (ADR §10 — C's finding). Held **inside the real
169/// lane's encrypted records**, so the decoy session can neither read nor forge it. On each real
170/// review the anchor pins the branch's head + record count; a later review that finds the witnessed
171/// prefix truncated or rewritten flags forensic tampering the unkeyed hash chain alone can't prove.
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
173struct BranchAnchor {
174    branch_ref: String,
175    head_id_hex: String,
176    record_count: u64,
177}
178
179/// The decrypted per-lane records payload. Both lanes serialize this; only the **real** lane ever
180/// populates `anchors` / `retention` (the decoy lane's stay empty — the retention setting must be
181/// invisible and unreachable from a decoy session, ADR §8).
182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
183struct LaneData {
184    #[serde(default)]
185    notes: Vec<SanctuaryVaultNote>,
186    #[serde(default)]
187    anchors: Vec<BranchAnchor>,
188    /// Decoy-audit retention policy, held in (and settable only from) the real lane. `None` → the
189    /// default ([`RetentionMode::AutoArchive`]).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    retention: Option<RetentionMode>,
192}
193
194// --- CBOR helpers (the vault serializes nothing as JSON) ---
195
196fn cbor_encode<T: Serialize>(value: &T) -> Result<Vec<u8>, String> {
197    let mut buf = Vec::new();
198    ciborium::into_writer(value, &mut buf).map_err(|e| e.to_string())?;
199    Ok(buf)
200}
201
202fn cbor_decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
203    ciborium::from_reader(bytes).map_err(|e| e.to_string())
204}
205
206fn vault_path(root: &Path) -> PathBuf {
207    root.join(SANCTUARY_VAULT_FILE)
208}
209
210fn random_salt() -> [u8; 16] {
211    // uuid v4 is CSPRNG-backed (getrandom); 16 bytes is ample salt entropy.
212    uuid::Uuid::new_v4().into_bytes()
213}
214
215/// The production Argon2id KDF descriptor (ADR D1).
216fn argon2_default_kdf() -> KdfDescriptor {
217    KdfDescriptor::Argon2id {
218        m_cost_kib: ARGON2_M_COST_KIB,
219        t_cost: ARGON2_T_COST,
220        p_cost: ARGON2_P_COST,
221    }
222}
223
224fn seal(key: &SanctuaryKeyMaterial, chunk: u64, plaintext: &[u8], aad: &[u8]) -> EncBlob {
225    let mut ct = vec![0u8; plaintext.len()];
226    let mut tag = [0u8; SANCTUARY_TAG_BYTES];
227    // AES-256-GCM over caller buffers; deterministic nonce from (tweak, chunk) — chunk is a
228    // monotonic counter here, so nonces never repeat under one key.
229    encrypt_sanctuary_chunk(ALGO, key, chunk, plaintext, &mut ct, &mut tag, aad)
230        .expect("aead encrypt");
231    EncBlob {
232        chunk_index: chunk,
233        ct_hex: hex::encode(&ct),
234        tag_hex: hex::encode(tag),
235    }
236}
237
238fn open(key: &SanctuaryKeyMaterial, blob: &EncBlob, aad: &[u8]) -> Result<Vec<u8>, String> {
239    let ct = hex::decode(&blob.ct_hex).map_err(|e| e.to_string())?;
240    let tag_bytes = hex::decode(&blob.tag_hex).map_err(|e| e.to_string())?;
241    if tag_bytes.len() != SANCTUARY_TAG_BYTES {
242        return Err("corrupt sanctuary tag".into());
243    }
244    let mut tag = [0u8; SANCTUARY_TAG_BYTES];
245    tag.copy_from_slice(&tag_bytes);
246    let mut pt = vec![0u8; ct.len()];
247    decrypt_sanctuary_chunk(ALGO, key, blob.chunk_index, &ct, &tag, &mut pt, aad)
248        .map_err(|_| "sanctuary decryption failed (wrong PIN or tampered vault)".to_string())?;
249    Ok(pt)
250}
251
252/// Derive the 32-byte **wrapping key** for a lane from its derived key material. Domain-separated
253/// with SHA-256 so it is never the same bytes as the lane's AEAD cipher key (which encrypts the
254/// records), even though both descend from the same PIN stretch. Used only for the one-way key
255/// hierarchy (`wrap_key`/`unwrap_key`, which is XChaCha20-Poly1305 internally).
256fn wrapping_key_from(material: &SanctuaryKeyMaterial) -> [u8; 32] {
257    let mut h = Sha256::new();
258    h.update(b"q42:sanctuary:wrap-key:v1");
259    h.update(material.cipher_key);
260    h.update(material.volume_tweak);
261    let out = h.finalize();
262    let mut k = [0u8; 32];
263    k.copy_from_slice(&out);
264    k
265}
266
267/// Serialize lane key material to its 48-byte form (`cipher_key(32) ‖ volume_tweak(16)`) for wrapping.
268fn material_to_bytes(m: &SanctuaryKeyMaterial) -> [u8; KEY_MATERIAL_BYTES] {
269    let mut b = [0u8; KEY_MATERIAL_BYTES];
270    b[..32].copy_from_slice(&m.cipher_key);
271    b[32..].copy_from_slice(&m.volume_tweak);
272    b
273}
274
275/// Reconstruct lane key material from its 48-byte form.
276fn material_from_bytes(b: &[u8]) -> Result<SanctuaryKeyMaterial, String> {
277    if b.len() != KEY_MATERIAL_BYTES {
278        return Err("wrapped decoy key material has the wrong length".into());
279    }
280    let mut cipher_key = [0u8; 32];
281    let mut volume_tweak = [0u8; 16];
282    cipher_key.copy_from_slice(&b[..32]);
283    volume_tweak.copy_from_slice(&b[32..48]);
284    Ok(SanctuaryKeyMaterial {
285        cipher_key,
286        volume_tweak,
287    })
288}
289
290/// Fold an optional OS-keychain pepper into the PIN before the KDF stretch. When no pepper is
291/// present (the default, unwrapped vault) the PIN bytes are used verbatim — so unwrapped vaults
292/// derive exactly as before. A pepper domain-separates and binds the derivation to the keychain
293/// secret: disk + PIN alone cannot reproduce the key.
294fn effective_secret(pin: &str, pepper: Option<&[u8; 32]>) -> Vec<u8> {
295    match pepper {
296        None => pin.as_bytes().to_vec(),
297        Some(p) => {
298            let mut h = Sha256::new();
299            h.update(b"q42:sanctuary:pepper:v1");
300            h.update(p);
301            h.update(pin.as_bytes());
302            h.finalize().to_vec()
303        }
304    }
305}
306
307/// Resolve a layer's KDF. Every real/decoy layer a vault creates carries an explicit descriptor;
308/// only reserved padding layers have `None`, and those are never opened. There is no legacy
309/// vault-level `iterations` fallback (no JSON vaults ever existed).
310fn resolve_kdf(layer: &Layer) -> Result<KdfDescriptor, String> {
311    layer
312        .kdf
313        .clone()
314        .ok_or_else(|| "sanctuary layer has no KDF descriptor".to_string())
315}
316
317/// Derive 48-byte lane key material under the layer's KDF.
318fn derive_lane_material(
319    kdf: &KdfDescriptor,
320    secret: &[u8],
321    salt: &[u8],
322) -> Result<SanctuaryKeyMaterial, String> {
323    match kdf {
324        KdfDescriptor::Pbkdf2 { iterations } => {
325            Ok(derive_sanctuary_key_material(secret, salt, *iterations))
326        }
327        KdfDescriptor::Argon2id {
328            m_cost_kib,
329            t_cost,
330            p_cost,
331        } => derive_sanctuary_key_material_argon2(secret, salt, *m_cost_kib, *t_cost, *p_cost),
332    }
333}
334
335fn lane_key(
336    pin: &str,
337    layer: &Layer,
338    pepper: Option<&[u8; 32]>,
339) -> Result<SanctuaryKeyMaterial, String> {
340    let salt = hex::decode(&layer.salt_hex).map_err(|e| e.to_string())?;
341    let secret = effective_secret(pin, pepper);
342    derive_lane_material(&resolve_kdf(layer)?, &secret, &salt)
343}
344
345/// Derive the key for a layer and confirm the PIN opens it (verifier → magic constant).
346fn try_lane(
347    pin: &str,
348    layer: &Layer,
349    lane_id: SanctuaryLane,
350    pepper: Option<&[u8; 32]>,
351) -> Option<SanctuaryKeyMaterial> {
352    let key = lane_key(pin, layer, pepper).ok()?;
353    match open(&key, &layer.verifier, lane_id.aad()) {
354        Ok(v) if v == VERIFIER_MAGIC => Some(key),
355        _ => None,
356    }
357}
358
359/// Resolve which lane a PIN opens, returning the key alongside it.
360///
361/// **Constant-work across the three outcomes** (real PIN / duress-decoy PIN / wrong PIN): ALWAYS
362/// derive BOTH lane keys — one full KDF stretch each — before branching on which verifier opened.
363/// Early-returning once the real lane matched would make a real unlock cost one KDF while a duress
364/// or wrong PIN costs two — a ~2x timing tell a coercer could use to single out the real PIN (i.e.
365/// to learn that a *hidden* primary vault exists behind the decoy). The residual difference (an
366/// AEAD tag verify that succeeds on one lane vs fails on the other, plus a short magic-constant
367/// compare) is microseconds beside two memory-hard KDF stretches. This is *equal KDF work*, not a
368/// proof of microarchitectural constant-time — see the Sanctuary threat-model ADR (D4).
369fn open_lane(
370    container: &VaultContainerV2,
371    pin: &str,
372    pepper: Option<&[u8; 32]>,
373) -> Result<(SanctuaryLane, SanctuaryKeyMaterial), String> {
374    let real = container
375        .layer_by_role(LayerRole::Real)
376        .ok_or("Sanctuary vault is missing its real layer")?;
377    let decoy = container
378        .layer_by_role(LayerRole::Decoy)
379        .ok_or("Sanctuary vault is missing its decoy layer")?;
380    let real_key = try_lane(pin, real, SanctuaryLane::Real, pepper);
381    let decoy_key = try_lane(pin, decoy, SanctuaryLane::Decoy, pepper);
382    if let Some(key) = real_key {
383        return Ok((SanctuaryLane::Real, key));
384    }
385    if let Some(key) = decoy_key {
386        return Ok((SanctuaryLane::Decoy, key));
387    }
388    Err("Incorrect PIN".into())
389}
390
391/// Build a fresh lane and return it alongside its derived key material (needed by [`build_vault`]
392/// to wire the real→decoy key hierarchy). The `audit_pubkey_hex` / `wrapped_keys` are filled in by
393/// the caller, since they depend on the *sibling* lane's key.
394fn new_lane(
395    pin: &str,
396    lane_id: SanctuaryLane,
397    kdf: KdfDescriptor,
398    pepper: Option<&[u8; 32]>,
399) -> Result<(Layer, SanctuaryKeyMaterial), String> {
400    let salt = random_salt();
401    let secret = effective_secret(pin, pepper);
402    let key = derive_lane_material(&kdf, &secret, &salt)?;
403    let aad = lane_id.aad();
404    let verifier = seal(&key, VERIFIER_CHUNK, VERIFIER_MAGIC, aad);
405    let records_pt = cbor_encode(&LaneData::default())?;
406    let records = seal(&key, 0, &records_pt, aad);
407    let layer = Layer {
408        id: lane_id.layer_id().to_string(),
409        role: lane_id.role(),
410        salt_hex: hex::encode(salt),
411        kdf: Some(kdf),
412        verifier,
413        records,
414        next_counter: 1,
415        audit_pubkey_hex: None,
416        wrapped_keys: Vec::new(),
417    };
418    Ok((layer, key))
419}
420
421fn load_container(root: &Path) -> Result<Option<VaultContainerV2>, String> {
422    let path = vault_path(root);
423    if !path.exists() {
424        return Ok(None);
425    }
426    let raw = fs::read(&path).map_err(|e| e.to_string())?;
427    Ok(Some(VaultContainerV2::from_cbor(&raw)?))
428}
429
430fn save_container(root: &Path, container: &VaultContainerV2) -> Result<(), String> {
431    let path = vault_path(root);
432    if let Some(parent) = path.parent() {
433        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
434    }
435    let bytes = container.to_cbor()?;
436    fs::write(&path, bytes).map_err(|e| e.to_string())
437}
438
439/// Is the encrypted vault configured on disk?
440pub fn is_configured(root: impl AsRef<Path>) -> bool {
441    vault_path(root.as_ref()).exists()
442}
443
444/// Create the two encrypted lanes with the production **Argon2id** KDF (memory-hard; ADR D1).
445/// Fails if a PIN is too weak, the PINs are equal, or the vault already exists.
446pub fn setup(root: impl AsRef<Path>, real_pin: &str, decoy_pin: &str) -> Result<(), String> {
447    build_vault(root, real_pin, decoy_pin, argon2_default_kdf(), None, None)
448}
449
450/// As [`setup`] but with an explicit PBKDF2 iteration count. Crate-internal so **tests** can use a
451/// fast work factor; production goes through [`setup`] (Argon2id). Only referenced from tests, so a
452/// non-test build sees it unused.
453#[cfg_attr(not(test), allow(dead_code))]
454pub(crate) fn setup_with_iterations(
455    root: impl AsRef<Path>,
456    real_pin: &str,
457    decoy_pin: &str,
458    iterations: u32,
459) -> Result<(), String> {
460    build_vault(
461        root,
462        real_pin,
463        decoy_pin,
464        KdfDescriptor::Pbkdf2 { iterations },
465        None,
466        None,
467    )
468}
469
470/// Shared vault constructor. `pepper`/`vault_id` are `Some` only for a keychain-wrapped vault.
471fn build_vault(
472    root: impl AsRef<Path>,
473    real_pin: &str,
474    decoy_pin: &str,
475    kdf: KdfDescriptor,
476    pepper: Option<&[u8; 32]>,
477    vault_id: Option<String>,
478) -> Result<(), String> {
479    validate_pin_strength(real_pin)?;
480    validate_pin_strength(decoy_pin)?;
481    if real_pin == decoy_pin {
482        return Err("Decoy PIN must differ from the real unlock PIN".into());
483    }
484    let root = root.as_ref();
485    if is_configured(root) {
486        return Err("Sanctuary vault already exists".into());
487    }
488    let (mut real, real_key) = new_lane(real_pin, SanctuaryLane::Real, kdf.clone(), pepper)?;
489    let (mut decoy, decoy_key) = new_lane(decoy_pin, SanctuaryLane::Decoy, kdf, pepper)?;
490
491    // --- Wire the real→decoy key hierarchy + the blind audit channel (ADR §3.1/§3.2) ---
492    // 1. A fresh audit keypair. Its PUBLIC key lives (plaintext) on the decoy layer so a decoy
493    //    session can seal records to it; its SECRET is wrapped under the real key so only the real
494    //    lane can read them.
495    let audit = AuditKeypair::generate().map_err(|e| format!("audit keypair: {e:?}"))?;
496    decoy.audit_pubkey_hex = Some(hex::encode(audit.public));
497
498    // 2. The real lane's wrapping key wraps the decoy lane key + the audit secret (one-way: the
499    //    decoy holds nothing that unwraps toward real).
500    let real_wrap = wrapping_key_from(&real_key);
501    let wrapped_decoy = wrap_key(&real_wrap, &material_to_bytes(&decoy_key), WRAP_AAD_DECOY)
502        .map_err(|e| format!("wrap decoy key: {e:?}"))?;
503    let wrapped_audit = wrap_key(&real_wrap, audit.secret_bytes(), WRAP_AAD_AUDIT)
504        .map_err(|e| format!("wrap audit secret: {e:?}"))?;
505    real.wrapped_keys = vec![
506        WrappedKey {
507            purpose: WK_DECOY_LANE_KEY.into(),
508            blob_hex: hex::encode(&wrapped_decoy),
509        },
510        WrappedKey {
511            purpose: WK_AUDIT_SECRET.into(),
512            blob_hex: hex::encode(&wrapped_audit),
513        },
514    ];
515
516    // `new` pads to the constant layer shape (reserved layers) and stamps the v2 version.
517    let container = VaultContainerV2::new(vec![real, decoy], pepper.is_some(), vault_id)?;
518    save_container(root, &container)
519}
520
521/// Unwrap the decoy lane key material using the (already-unlocked) real lane key. Fails if the
522/// vault has no wrapped decoy key or the wrap is tampered — a decoy or wrong key never reaches here.
523fn unwrap_decoy_material(
524    container: &VaultContainerV2,
525    real_key: &SanctuaryKeyMaterial,
526) -> Result<SanctuaryKeyMaterial, String> {
527    let real_layer = container
528        .layer_by_role(LayerRole::Real)
529        .ok_or("Sanctuary vault is missing its real layer")?;
530    let wrapped = real_layer
531        .wrapped_key(WK_DECOY_LANE_KEY)
532        .ok_or("Sanctuary vault has no wrapped decoy key")?;
533    let blob = hex::decode(&wrapped.blob_hex).map_err(|e| e.to_string())?;
534    let real_wrap = wrapping_key_from(real_key);
535    let raw = unwrap_key(&real_wrap, &blob, WRAP_AAD_DECOY)
536        .map_err(|_| "could not unwrap the decoy lane key (tampered vault?)".to_string())?;
537    material_from_bytes(&raw)
538}
539
540/// **Real-session decoy curation (ADR §3.2).** From a real unlock, write a note into the decoy lane
541/// *without* the decoy PIN — the real lane unwraps the decoy key from its one-way hierarchy. This is
542/// how the victim keeps the decoy lived-in and plausible so a coercer's re-unlock shows believable
543/// content. Requires the **real** PIN; supplying the decoy PIN is rejected (the decoy cannot curate
544/// itself, and must not be able to detect that curation exists).
545pub fn real_curate_decoy_add_note(
546    root: impl AsRef<Path>,
547    real_pin: &str,
548    body: &str,
549    now_unix: u32,
550) -> Result<(), String> {
551    let root = root.as_ref();
552    let mut container = load_container(root)?.ok_or("Sanctuary vault is not set up")?;
553    let pepper = pepper_for(&container, None)?;
554    let (lane, real_key) = open_lane(&container, real_pin, pepper.as_ref())?;
555    if lane != SanctuaryLane::Real {
556        return Err("Curating the decoy requires the real unlock PIN".into());
557    }
558    let decoy_key = unwrap_decoy_material(&container, &real_key)?;
559
560    // Read → append → re-seal the decoy records under the unwrapped decoy key (same key + monotonic
561    // counter the decoy-PIN path uses, so nonces never collide).
562    let aad = SanctuaryLane::Decoy.aad();
563    let (mut data, counter) = {
564        let layer = layer_ref(&container, SanctuaryLane::Decoy)?;
565        let pt = open(&decoy_key, &layer.records, aad)?;
566        let data: LaneData = cbor_decode(&pt)?;
567        (data, layer.next_counter)
568    };
569    data.notes.push(SanctuaryVaultNote {
570        id: uuid::Uuid::new_v4().to_string(),
571        body: body.to_string(),
572        created_at_unix: now_unix,
573    });
574    let records_pt = cbor_encode(&data)?;
575    let blob = seal(&decoy_key, counter, &records_pt, aad);
576    {
577        let layer = layer_mut(&mut container, SanctuaryLane::Decoy)?;
578        layer.records = blob;
579        layer.next_counter = counter.saturating_add(1);
580    }
581    save_container(root, &container)
582}
583
584/// Resolve the pepper needed to open `container`. `override_pepper` (a recovery code) wins;
585/// otherwise a keychain-wrapped vault fetches its pepper from the OS keychain (erroring if gone).
586fn pepper_for(
587    container: &VaultContainerV2,
588    override_pepper: Option<[u8; 32]>,
589) -> Result<Option<[u8; 32]>, String> {
590    if let Some(p) = override_pepper {
591        return Ok(Some(p));
592    }
593    if !container.keychain_wrapped {
594        return Ok(None);
595    }
596    let vault_id = container
597        .vault_id
598        .as_deref()
599        .ok_or("Keychain-wrapped vault is missing its vault_id")?;
600    sanctuary_keychain::get_pepper(vault_id)?
601        .map(Some)
602        .ok_or_else(|| {
603            "Sanctuary keychain secret not found on this device — supply the recovery code".into()
604        })
605}
606
607/// Is the on-disk vault keychain-wrapped (T1.2)?
608pub fn is_keychain_wrapped(root: impl AsRef<Path>) -> bool {
609    load_container(root.as_ref())
610        .ok()
611        .flatten()
612        .map(|c| c.keychain_wrapped)
613        .unwrap_or(false)
614}
615
616/// **Opt-in (off by default).** Create the two encrypted lanes with an OS-keychain-held pepper mixed
617/// into the KDF, so disk + PIN alone cannot open the vault. Returns the pepper as a hex **recovery
618/// code**: the caller MUST have the user record it out-of-band — if the keychain entry is later lost
619/// (reinstall / new machine), this code is the only way back in (see [`unlock_with_recovery`]).
620///
621/// Enabling this is a deliberate, recovery-aware choice; the ordinary [`setup`] path stays unwrapped.
622pub fn setup_wrapped(
623    root: impl AsRef<Path>,
624    real_pin: &str,
625    decoy_pin: &str,
626) -> Result<String, String> {
627    let pepper = sanctuary_keychain::generate_pepper()?;
628    let vault_id = uuid::Uuid::new_v4().to_string();
629    // Store the pepper first; if vault construction then fails, roll the keychain entry back.
630    sanctuary_keychain::store_pepper(&vault_id, &pepper)?;
631    match build_vault(
632        root,
633        real_pin,
634        decoy_pin,
635        argon2_default_kdf(),
636        Some(&pepper),
637        Some(vault_id.clone()),
638    ) {
639        Ok(()) => Ok(hex::encode(pepper)),
640        Err(e) => {
641            let _ = sanctuary_keychain::delete_pepper(&vault_id);
642            Err(e)
643        }
644    }
645}
646
647/// Recover access to a keychain-wrapped vault whose keychain entry is missing (new device, OS
648/// reinstall) by supplying the hex recovery code from [`setup_wrapped`]. On success the pepper is
649/// re-stored into this device's keychain so subsequent unlocks are seamless again.
650pub fn unlock_with_recovery(
651    root: impl AsRef<Path>,
652    pin: &str,
653    recovery_code_hex: &str,
654) -> Result<SanctuaryLane, String> {
655    let container = load_container(root.as_ref())?.ok_or("Sanctuary vault is not set up")?;
656    if !container.keychain_wrapped {
657        return Err("This vault is not keychain-wrapped; unlock with the PIN alone".into());
658    }
659    let bytes = hex::decode(recovery_code_hex.trim())
660        .map_err(|_| "Recovery code is not valid hex".to_string())?;
661    if bytes.len() != 32 {
662        return Err("Recovery code must be 32 bytes (64 hex chars)".into());
663    }
664    let mut pepper = [0u8; 32];
665    pepper.copy_from_slice(&bytes);
666    let (lane, _key) = open_lane(&container, pin, Some(&pepper))?;
667    if let Some(vault_id) = container.vault_id.as_deref() {
668        // Re-seat the pepper for future unlocks on this device.
669        sanctuary_keychain::store_pepper(vault_id, &pepper)?;
670    }
671    Ok(lane)
672}
673
674/// Resolve which lane a PIN opens (or an error if it opens neither).
675pub fn resolve_lane(root: impl AsRef<Path>, pin: &str) -> Result<SanctuaryLane, String> {
676    let container = load_container(root.as_ref())?.ok_or("Sanctuary vault is not set up")?;
677    let pepper = pepper_for(&container, None)?;
678    Ok(open_lane(&container, pin, pepper.as_ref())?.0)
679}
680
681fn layer_ref(container: &VaultContainerV2, lane: SanctuaryLane) -> Result<&Layer, String> {
682    container
683        .layer_by_role(lane.role())
684        .ok_or_else(|| "Sanctuary vault is missing a lane layer".to_string())
685}
686
687fn layer_mut(container: &mut VaultContainerV2, lane: SanctuaryLane) -> Result<&mut Layer, String> {
688    let role = lane.role();
689    container
690        .layers
691        .iter_mut()
692        .find(|l| l.role == role)
693        .ok_or_else(|| "Sanctuary vault is missing a lane layer".to_string())
694}
695
696/// Read the notes held in the lane the PIN opens. Nothing is readable without a valid PIN.
697pub fn list_notes(
698    root: impl AsRef<Path>,
699    pin: &str,
700) -> Result<(SanctuaryLane, Vec<SanctuaryVaultNote>), String> {
701    let container = load_container(root.as_ref())?.ok_or("Sanctuary vault is not set up")?;
702    let pepper = pepper_for(&container, None)?;
703    let (lane, key) = open_lane(&container, pin, pepper.as_ref())?;
704    let layer = layer_ref(&container, lane)?;
705    let pt = open(&key, &layer.records, lane.aad())?;
706    let data: LaneData = cbor_decode(&pt)?;
707    Ok((lane, data.notes))
708}
709
710/// Append a note to the lane the PIN opens (real PIN → real lane; duress PIN → decoy only).
711///
712/// A **decoy** write also leaves a blind, sealed audit record on [`DEFAULT_DECOY_BRANCH`] that the
713/// coercer cannot read; the real lane reviews it via [`review_decoy_activity`]. For git-like
714/// per-session branches (one branch per duress unlock), use [`add_note_in_session`].
715pub fn add_note(
716    root: impl AsRef<Path>,
717    pin: &str,
718    body: &str,
719    now_unix: u32,
720) -> Result<SanctuaryLane, String> {
721    add_note_in_session(root, pin, body, now_unix, DEFAULT_DECOY_BRANCH)
722}
723
724/// As [`add_note`], but a **decoy** write is attributed to `session_ref` — a fresh ref per duress
725/// unlock yields the git-like per-session branch (ADR §10). Ignored for real-lane writes (real
726/// activity is never audited).
727pub fn add_note_in_session(
728    root: impl AsRef<Path>,
729    pin: &str,
730    body: &str,
731    now_unix: u32,
732    session_ref: &str,
733) -> Result<SanctuaryLane, String> {
734    let root = root.as_ref();
735    let mut container = load_container(root)?.ok_or("Sanctuary vault is not set up")?;
736    let pepper = pepper_for(&container, None)?;
737    let (lane, key) = open_lane(&container, pin, pepper.as_ref())?;
738
739    // Read → append → re-seal this lane's records.
740    let (mut data, counter) = {
741        let layer = layer_ref(&container, lane)?;
742        let pt = open(&key, &layer.records, lane.aad())?;
743        let data: LaneData = cbor_decode(&pt)?;
744        (data, layer.next_counter)
745    };
746    data.notes.push(SanctuaryVaultNote {
747        id: uuid::Uuid::new_v4().to_string(),
748        body: body.to_string(),
749        created_at_unix: now_unix,
750    });
751    let records_pt = cbor_encode(&data)?;
752    let blob = seal(&key, counter, &records_pt, lane.aad());
753    {
754        let layer = layer_mut(&mut container, lane)?;
755        layer.records = blob;
756        layer.next_counter = counter.saturating_add(1);
757    }
758
759    // A decoy (duress) write leaves a blind, sealed trail the coercer can neither read nor forge.
760    if lane == SanctuaryLane::Decoy {
761        record_decoy_action(
762            &mut container,
763            session_ref,
764            AuditAction::AddNote,
765            body.as_bytes(),
766            now_unix,
767        )?;
768    }
769
770    save_container(root, &container)?;
771    Ok(lane)
772}
773
774// --- Blind audit append (decoy-session writes) ---
775
776/// The decoy layer's audit **public** key (a decoy session seals its records to it).
777fn decoy_audit_pubkey(container: &VaultContainerV2) -> Result<[u8; 32], String> {
778    let decoy = container
779        .layer_by_role(LayerRole::Decoy)
780        .ok_or("Sanctuary vault is missing its decoy layer")?;
781    let hex_s = decoy
782        .audit_pubkey_hex
783        .as_ref()
784        .ok_or("decoy layer has no audit public key")?;
785    let bytes = hex::decode(hex_s).map_err(|e| e.to_string())?;
786    if bytes.len() != 32 {
787        return Err("decoy audit public key has the wrong length".into());
788    }
789    let mut k = [0u8; 32];
790    k.copy_from_slice(&bytes);
791    Ok(k)
792}
793
794/// The id of the last record on `branch_ref` in append order (the branch head), if any.
795fn last_head_for_branch(log: &[AuditRecord], branch_ref: &str) -> Option<[u8; 32]> {
796    log.iter()
797        .rev()
798        .find(|r| r.branch_ref == branch_ref)
799        .map(|r| r.id)
800}
801
802/// Append one sealed audit record for a decoy action. On the first record of a branch, an
803/// `OpenSession` record is appended first so every branch has a genesis entry-point.
804fn record_decoy_action(
805    container: &mut VaultContainerV2,
806    branch_ref: &str,
807    action: AuditAction,
808    payload: &[u8],
809    now_unix: u32,
810) -> Result<(), String> {
811    let audit_pub = decoy_audit_pubkey(container)?;
812    if last_head_for_branch(&container.audit_log, branch_ref).is_none() {
813        append_sealed(
814            container,
815            &audit_pub,
816            branch_ref,
817            AuditAction::OpenSession,
818            b"session opened",
819            now_unix,
820        )?;
821    }
822    append_sealed(container, &audit_pub, branch_ref, action, payload, now_unix)
823}
824
825/// Seal `payload` to the audit public key and push a content-addressed record onto the branch. The
826/// chain link is `chain_hash(parent ‖ canonical_bytes)` inside [`AuditRecord::new`]; `parent` is the
827/// current branch head (or genesis).
828fn append_sealed(
829    container: &mut VaultContainerV2,
830    audit_pub: &[u8; 32],
831    branch_ref: &str,
832    action: AuditAction,
833    payload: &[u8],
834    now_unix: u32,
835) -> Result<(), String> {
836    let parent = last_head_for_branch(&container.audit_log, branch_ref).unwrap_or(GENESIS_PARENT);
837    let sealed = seal_to(audit_pub, payload, branch_ref.as_bytes())
838        .map_err(|e| format!("seal audit record: {e:?}"))?;
839    let rec = AuditRecord::new(
840        parent,
841        branch_ref,
842        DECOY_ACTOR_DID,
843        Some("decoy-session".into()),
844        None,
845        action,
846        now_unix,
847        sealed,
848    );
849    container.audit_log.push(rec);
850    Ok(())
851}
852
853fn action_label(a: &AuditAction) -> String {
854    match a {
855        AuditAction::OpenSession => "open_session".into(),
856        AuditAction::AddNote => "add_note".into(),
857        AuditAction::EditNote => "edit_note".into(),
858        AuditAction::DeleteNote => "delete_note".into(),
859        AuditAction::Other(s) => format!("other:{s}"),
860    }
861}
862
863// --- Real-lane audit review + head anchor (ADR §10 — C's finding) ---
864
865/// The integrity verdict of the decoy audit log at review time.
866///
867/// * `Ok` — every branch's hash chain verifies and every witnessed prefix is intact.
868/// * `ChainBroken` — a branch's on-disk chain is internally broken (rewrite/reorder/drop caught by
869///   the content-address chain itself).
870/// * `WitnessedPrefixAltered` — a prefix the real lane had **already witnessed** (anchored) is now
871///   truncated or replaced. This is the forensic-tamper case the unkeyed chain alone cannot prove
872///   (a coercer can forge a fresh consistent chain); the real-lane head anchor catches it.
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874#[serde(rename_all = "snake_case")]
875pub enum AuditIntegrity {
876    Ok,
877    ChainBroken { branch_ref: String },
878    WitnessedPrefixAltered { branch_ref: String },
879}
880
881fn integrity_rank(i: &AuditIntegrity) -> u8 {
882    match i {
883        AuditIntegrity::Ok => 0,
884        AuditIntegrity::ChainBroken { .. } => 1,
885        AuditIntegrity::WitnessedPrefixAltered { .. } => 2,
886    }
887}
888
889fn escalate(current: AuditIntegrity, candidate: AuditIntegrity) -> AuditIntegrity {
890    if integrity_rank(&candidate) > integrity_rank(&current) {
891        candidate
892    } else {
893        current
894    }
895}
896
897/// One decrypted decoy action surfaced to the real lane.
898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899pub struct DecoyActionView {
900    pub branch_ref: String,
901    /// `open_session` / `add_note` / `edit_note` / `delete_note` / `other:<label>`.
902    pub action: String,
903    pub actor_did: String,
904    pub unix: u32,
905    /// The decrypted payload (the coercer's note body), lossy-UTF8; empty if it could not be opened.
906    pub payload: String,
907}
908
909/// The result of reviewing the decoy audit log from the real lane.
910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911pub struct DecoyActivityReport {
912    pub integrity: AuditIntegrity,
913    /// Number of distinct entry-point sessions (branches). A **proxy / lower bound** for "number of
914    /// attackers", NOT a verified head-count — shared credentials or one persistent actor across
915    /// sessions both defeat a naive count (ADR §10, audit-DAG module docs). The UI must not claim a
916    /// hard attacker count.
917    pub session_count: usize,
918    pub actions: Vec<DecoyActionView>,
919    /// The current decoy-audit retention policy (real-lane setting; ADR §8).
920    pub retention_mode: RetentionMode,
921}
922
923/// **Review decoy activity from the real lane (ADR §3.1 / §10).** Opens the real lane, unwraps the
924/// audit secret, decrypts every sealed decoy-session record, verifies chain integrity, and checks
925/// each previously-witnessed prefix against its head anchor (detecting forensic truncation/replace).
926/// Then advances the anchors for clean branches and persists them inside the real lane's encrypted
927/// records. Requires the **real** PIN; the decoy PIN is rejected.
928pub fn review_decoy_activity(
929    root: impl AsRef<Path>,
930    real_pin: &str,
931) -> Result<DecoyActivityReport, String> {
932    use std::collections::HashMap;
933
934    let root = root.as_ref();
935    let mut container = load_container(root)?.ok_or("Sanctuary vault is not set up")?;
936    let pepper = pepper_for(&container, None)?;
937    let (lane, real_key) = open_lane(&container, real_pin, pepper.as_ref())?;
938    if lane != SanctuaryLane::Real {
939        return Err("Reviewing decoy activity requires the real unlock PIN".into());
940    }
941
942    // Unwrap the audit secret + read the real lane's records (notes + prior anchors), then drop the
943    // immutable borrow before any mutation.
944    let (wrapped_audit_hex, real_records) = {
945        let real_layer = container
946            .layer_by_role(LayerRole::Real)
947            .ok_or("Sanctuary vault is missing its real layer")?;
948        let wrapped = real_layer
949            .wrapped_key(WK_AUDIT_SECRET)
950            .ok_or("Sanctuary vault has no wrapped audit secret")?;
951        (wrapped.blob_hex.clone(), real_layer.records.clone())
952    };
953    let real_wrap = wrapping_key_from(&real_key);
954    let secret_bytes = unwrap_key(
955        &real_wrap,
956        &hex::decode(&wrapped_audit_hex).map_err(|e| e.to_string())?,
957        WRAP_AAD_AUDIT,
958    )
959    .map_err(|_| "could not unwrap the audit secret (tampered vault?)".to_string())?;
960    if secret_bytes.len() != 32 {
961        return Err("audit secret has the wrong length".into());
962    }
963    let mut audit_secret = [0u8; 32];
964    audit_secret.copy_from_slice(&secret_bytes);
965
966    let mut data: LaneData =
967        cbor_decode(&open(&real_key, &real_records, SanctuaryLane::Real.aad())?)?;
968    let prior: HashMap<String, BranchAnchor> = data
969        .anchors
970        .iter()
971        .cloned()
972        .map(|a| (a.branch_ref.clone(), a))
973        .collect();
974
975    let sessions = derive_sessions(&container.audit_log);
976    let session_count = sessions.len();
977
978    let mut integrity = AuditIntegrity::Ok;
979    let mut actions: Vec<DecoyActionView> = Vec::new();
980    let mut new_anchors = prior.clone();
981
982    for session in &sessions {
983        let branch = &session.branch_ref;
984        let chain_ok = verify_chain(&session.records) == ChainStatus::Ok;
985        let mut witnessed_altered = false;
986        if !chain_ok {
987            integrity = escalate(
988                integrity,
989                AuditIntegrity::ChainBroken {
990                    branch_ref: branch.clone(),
991                },
992            );
993        } else if let Some(anchor) = prior.get(branch) {
994            let n = anchor.record_count as usize;
995            if n > 0 {
996                witnessed_altered = session.records.len() < n
997                    || verify_chain(&session.records[..n]) != ChainStatus::Ok
998                    || hex::encode(session.records[n - 1].id) != anchor.head_id_hex;
999            }
1000            if witnessed_altered {
1001                integrity = escalate(
1002                    integrity,
1003                    AuditIntegrity::WitnessedPrefixAltered {
1004                        branch_ref: branch.clone(),
1005                    },
1006                );
1007            }
1008        }
1009
1010        for rec in &session.records {
1011            let payload = open_sealed(&audit_secret, &rec.sealed, branch.as_bytes())
1012                .map(|p| String::from_utf8_lossy(&p).into_owned())
1013                .unwrap_or_default();
1014            actions.push(DecoyActionView {
1015                branch_ref: branch.clone(),
1016                action: action_label(&rec.action),
1017                actor_did: rec.actor_did.clone(),
1018                unix: rec.unix,
1019                payload,
1020            });
1021        }
1022
1023        // Advance the anchor only for a clean branch; a tampered branch keeps its prior anchor as
1024        // standing evidence of what the real lane last witnessed.
1025        if chain_ok && !witnessed_altered {
1026            if let Some(last) = session.records.last() {
1027                new_anchors.insert(
1028                    branch.clone(),
1029                    BranchAnchor {
1030                        branch_ref: branch.clone(),
1031                        head_id_hex: hex::encode(last.id),
1032                        record_count: session.records.len() as u64,
1033                    },
1034                );
1035            }
1036        }
1037    }
1038
1039    // Persist updated anchors (inside the real lane's encrypted records) only if they changed.
1040    let mut anchors_vec: Vec<BranchAnchor> = new_anchors.into_values().collect();
1041    anchors_vec.sort_by(|a, b| a.branch_ref.cmp(&b.branch_ref));
1042    let mut prior_vec: Vec<BranchAnchor> = prior.into_values().collect();
1043    prior_vec.sort_by(|a, b| a.branch_ref.cmp(&b.branch_ref));
1044    if anchors_vec != prior_vec {
1045        data.anchors = anchors_vec;
1046        let counter = layer_ref(&container, SanctuaryLane::Real)?.next_counter;
1047        let records_pt = cbor_encode(&data)?;
1048        let blob = seal(&real_key, counter, &records_pt, SanctuaryLane::Real.aad());
1049        {
1050            let layer = layer_mut(&mut container, SanctuaryLane::Real)?;
1051            layer.records = blob;
1052            layer.next_counter = counter.saturating_add(1);
1053        }
1054        save_container(root, &container)?;
1055    }
1056
1057    let retention_mode = data.retention.unwrap_or_default();
1058    Ok(DecoyActivityReport {
1059        integrity,
1060        session_count,
1061        actions,
1062        retention_mode,
1063    })
1064}
1065
1066/// Read the decoy-audit retention policy (real-lane setting). Requires the **real** PIN; defaults to
1067/// [`RetentionMode::AutoArchive`] when never set.
1068pub fn get_retention_mode(root: impl AsRef<Path>, real_pin: &str) -> Result<RetentionMode, String> {
1069    let container = load_container(root.as_ref())?.ok_or("Sanctuary vault is not set up")?;
1070    let pepper = pepper_for(&container, None)?;
1071    let (lane, real_key) = open_lane(&container, real_pin, pepper.as_ref())?;
1072    if lane != SanctuaryLane::Real {
1073        return Err("Reading the retention setting requires the real unlock PIN".into());
1074    }
1075    let layer = layer_ref(&container, SanctuaryLane::Real)?;
1076    let data: LaneData = cbor_decode(&open(&real_key, &layer.records, SanctuaryLane::Real.aad())?)?;
1077    Ok(data.retention.unwrap_or_default())
1078}
1079
1080/// Set the decoy-audit retention policy (ADR §8). **Real-session only** — the setting is stored in
1081/// the real lane's encrypted records so it is invisible and unreachable from a decoy session (which
1082/// must never learn that auditing exists). Requires the **real** PIN.
1083pub fn set_retention_mode(
1084    root: impl AsRef<Path>,
1085    real_pin: &str,
1086    mode: RetentionMode,
1087) -> Result<(), String> {
1088    let root = root.as_ref();
1089    let mut container = load_container(root)?.ok_or("Sanctuary vault is not set up")?;
1090    let pepper = pepper_for(&container, None)?;
1091    let (lane, real_key) = open_lane(&container, real_pin, pepper.as_ref())?;
1092    if lane != SanctuaryLane::Real {
1093        return Err("Changing the retention setting requires the real unlock PIN".into());
1094    }
1095    let (mut data, counter) = {
1096        let layer = layer_ref(&container, SanctuaryLane::Real)?;
1097        let pt = open(&real_key, &layer.records, SanctuaryLane::Real.aad())?;
1098        let data: LaneData = cbor_decode(&pt)?;
1099        (data, layer.next_counter)
1100    };
1101    data.retention = Some(mode);
1102    let records_pt = cbor_encode(&data)?;
1103    let blob = seal(&real_key, counter, &records_pt, SanctuaryLane::Real.aad());
1104    {
1105        let layer = layer_mut(&mut container, SanctuaryLane::Real)?;
1106        layer.records = blob;
1107        layer.next_counter = counter.saturating_add(1);
1108    }
1109    save_container(root, &container)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::wellfair::vault_container::{CONTAINER_SLOTS, CONTAINER_VERSION};
1116
1117    #[test]
1118    fn setup_rejects_short_or_equal_pins() {
1119        let dir = tempfile::tempdir().unwrap();
1120        assert!(setup(dir.path(), "abc", "decoy-pin").is_err());
1121        assert!(setup(dir.path(), "same-pin", "same-pin").is_err());
1122    }
1123
1124    #[test]
1125    fn real_and_decoy_pins_open_distinct_lanes() {
1126        let dir = tempfile::tempdir().unwrap();
1127        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1128        assert_eq!(
1129            resolve_lane(dir.path(), "real-pin-1").unwrap(),
1130            SanctuaryLane::Real
1131        );
1132        assert_eq!(
1133            resolve_lane(dir.path(), "decoy-pin-2").unwrap(),
1134            SanctuaryLane::Decoy
1135        );
1136        assert!(resolve_lane(dir.path(), "wrong-pin").is_err());
1137    }
1138
1139    #[test]
1140    fn notes_are_lane_isolated_and_decoy_never_sees_real() {
1141        let dir = tempfile::tempdir().unwrap();
1142        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1143
1144        assert_eq!(
1145            add_note(dir.path(), "real-pin-1", "real secret", 10).unwrap(),
1146            SanctuaryLane::Real
1147        );
1148        assert_eq!(
1149            add_note(dir.path(), "decoy-pin-2", "decoy filler", 11).unwrap(),
1150            SanctuaryLane::Decoy
1151        );
1152        // A duress note must never land in the real lane.
1153        assert_eq!(
1154            add_note(dir.path(), "decoy-pin-2", "more decoy", 12).unwrap(),
1155            SanctuaryLane::Decoy
1156        );
1157
1158        let (lane, real_notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1159        assert_eq!(lane, SanctuaryLane::Real);
1160        assert_eq!(real_notes.len(), 1);
1161        assert_eq!(real_notes[0].body, "real secret");
1162
1163        let (lane, decoy_notes) = list_notes(dir.path(), "decoy-pin-2").unwrap();
1164        assert_eq!(lane, SanctuaryLane::Decoy);
1165        assert_eq!(decoy_notes.len(), 2);
1166        assert!(decoy_notes.iter().all(|n| n.body != "real secret"));
1167    }
1168
1169    #[test]
1170    fn plaintext_never_appears_on_disk() {
1171        let dir = tempfile::tempdir().unwrap();
1172        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1173        add_note(dir.path(), "real-pin-1", "TERMINALLY-SENSITIVE-STRING", 10).unwrap();
1174        // CBOR is binary now; scan the raw bytes for the sensitive substring.
1175        let raw = fs::read(vault_path(dir.path())).unwrap();
1176        let needle = b"TERMINALLY-SENSITIVE-STRING";
1177        assert!(
1178            !raw.windows(needle.len()).any(|w| w == needle),
1179            "sanctuary body leaked to disk"
1180        );
1181    }
1182
1183    #[test]
1184    fn on_disk_container_has_constant_shape_and_v2_version() {
1185        let dir = tempfile::tempdir().unwrap();
1186        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1187        let container = load_container(dir.path()).unwrap().unwrap();
1188        assert_eq!(container.version, CONTAINER_VERSION);
1189        assert_eq!(container.layers.len(), CONTAINER_SLOTS);
1190        // The audit log starts empty (populated only by decoy-session writes in S5c).
1191        assert!(container.audit_log.is_empty());
1192        // Vault file is the .cbor path, and it is not valid UTF-8 JSON text.
1193        assert!(vault_path(dir.path()).to_string_lossy().ends_with(".cbor"));
1194    }
1195
1196    #[test]
1197    fn effective_secret_folds_pepper_deterministically() {
1198        // No pepper → PIN verbatim (unwrapped vaults derive exactly as before).
1199        assert_eq!(effective_secret("pin", None), b"pin".to_vec());
1200        // A pepper changes the derived secret, deterministically.
1201        let peppered = effective_secret("pin", Some(&[1u8; 32]));
1202        assert_ne!(peppered, b"pin".to_vec());
1203        assert_eq!(peppered, effective_secret("pin", Some(&[1u8; 32])));
1204        assert_ne!(peppered, effective_secret("pin", Some(&[2u8; 32])));
1205    }
1206
1207    #[test]
1208    fn keychain_pepper_binds_the_vault_key() {
1209        // Hermetic: exercises the pepper-mixing directly via build_vault/open_lane, with NO real
1210        // OS-keychain I/O (setup_wrapped/unlock_with_recovery own that thin wrapper).
1211        let dir = tempfile::tempdir().unwrap();
1212        let pepper = [7u8; 32];
1213        build_vault(
1214            dir.path(),
1215            "real-pin-1",
1216            "decoy-pin-2",
1217            KdfDescriptor::Pbkdf2 { iterations: 1_000 },
1218            Some(&pepper),
1219            Some("test-vault".into()),
1220        )
1221        .unwrap();
1222
1223        let container = load_container(dir.path()).unwrap().unwrap();
1224        assert!(container.keychain_wrapped);
1225        assert_eq!(container.vault_id.as_deref(), Some("test-vault"));
1226
1227        // Correct pepper opens the real lane.
1228        let (lane, _k) = open_lane(&container, "real-pin-1", Some(&pepper)).unwrap();
1229        assert_eq!(lane, SanctuaryLane::Real);
1230
1231        // The same PIN with NO pepper (i.e. disk + PIN, as an attacker would try) does NOT open it.
1232        assert!(open_lane(&container, "real-pin-1", None).is_err());
1233        // A wrong pepper does not open it either.
1234        assert!(open_lane(&container, "real-pin-1", Some(&[9u8; 32])).is_err());
1235    }
1236
1237    #[test]
1238    fn unwrapped_vault_needs_no_pepper() {
1239        // Regression: the default (unwrapped) path resolves a None pepper and opens on PIN alone.
1240        let dir = tempfile::tempdir().unwrap();
1241        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1242        let container = load_container(dir.path()).unwrap().unwrap();
1243        assert!(!container.keychain_wrapped);
1244        assert!(pepper_for(&container, None).unwrap().is_none());
1245        assert!(!is_keychain_wrapped(dir.path()));
1246    }
1247
1248    #[test]
1249    fn argon2_vault_opens_and_isolates_lanes() {
1250        // Fast Argon2id params for the test; production uses 64 MiB (ADR D1).
1251        let dir = tempfile::tempdir().unwrap();
1252        let kdf = KdfDescriptor::Argon2id {
1253            m_cost_kib: 16,
1254            t_cost: 1,
1255            p_cost: 1,
1256        };
1257        build_vault(
1258            dir.path(),
1259            "real-pass-alpha",
1260            "decoy-pass-beta",
1261            kdf,
1262            None,
1263            None,
1264        )
1265        .unwrap();
1266        assert_eq!(
1267            resolve_lane(dir.path(), "real-pass-alpha").unwrap(),
1268            SanctuaryLane::Real
1269        );
1270        assert_eq!(
1271            resolve_lane(dir.path(), "decoy-pass-beta").unwrap(),
1272            SanctuaryLane::Decoy
1273        );
1274        assert!(resolve_lane(dir.path(), "wrong-pass-xyz").is_err());
1275        let container = load_container(dir.path()).unwrap().unwrap();
1276        assert!(matches!(
1277            container.layer_by_role(LayerRole::Real).unwrap().kdf,
1278            Some(KdfDescriptor::Argon2id { .. })
1279        ));
1280        assert!(matches!(
1281            container.layer_by_role(LayerRole::Decoy).unwrap().kdf,
1282            Some(KdfDescriptor::Argon2id { .. })
1283        ));
1284    }
1285
1286    #[test]
1287    fn production_setup_uses_argon2id() {
1288        // setup() must produce memory-hard Argon2id lanes (real 64 MiB params, one-time cost).
1289        let dir = tempfile::tempdir().unwrap();
1290        setup(dir.path(), "correct-horse", "battery-staple-2").unwrap();
1291        let container = load_container(dir.path()).unwrap().unwrap();
1292        assert!(matches!(
1293            container.layer_by_role(LayerRole::Real).unwrap().kdf,
1294            Some(KdfDescriptor::Argon2id { .. })
1295        ));
1296    }
1297
1298    #[test]
1299    fn pbkdf2_vault_opens() {
1300        // A PBKDF2-configured vault (fast test factor) opens on its PINs.
1301        let dir = tempfile::tempdir().unwrap();
1302        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1303        assert_eq!(
1304            resolve_lane(dir.path(), "real-pin-1").unwrap(),
1305            SanctuaryLane::Real
1306        );
1307    }
1308
1309    #[test]
1310    fn pin_policy_rejects_weak_pins() {
1311        let dir = tempfile::tempdir().unwrap();
1312        assert!(
1313            setup(dir.path(), "a1b2", "decoy-strong-1").is_err(),
1314            "too short"
1315        );
1316        assert!(
1317            setup(dir.path(), "aaaaaa", "decoy-strong-1").is_err(),
1318            "all identical"
1319        );
1320        assert!(
1321            setup(dir.path(), "123456", "decoy-strong-1").is_err(),
1322            "sequential"
1323        );
1324        assert!(
1325            setup(dir.path(), "password", "decoy-strong-1").is_err(),
1326            "common"
1327        );
1328        // None of the rejected attempts created a vault (they fail before derivation).
1329        assert!(!is_configured(dir.path()));
1330    }
1331
1332    #[test]
1333    fn tampered_ciphertext_fails_to_open() {
1334        let dir = tempfile::tempdir().unwrap();
1335        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1336        add_note(dir.path(), "real-pin-1", "secret", 10).unwrap();
1337        // Flip a byte in the stored real-layer ciphertext.
1338        let mut container = load_container(dir.path()).unwrap().unwrap();
1339        {
1340            let layer = layer_mut(&mut container, SanctuaryLane::Real).unwrap();
1341            let mut ct = hex::decode(&layer.records.ct_hex).unwrap();
1342            ct[0] ^= 0xFF;
1343            layer.records.ct_hex = hex::encode(&ct);
1344        }
1345        save_container(dir.path(), &container).unwrap();
1346        assert!(list_notes(dir.path(), "real-pin-1").is_err());
1347    }
1348
1349    #[test]
1350    fn survives_reopen() {
1351        let dir = tempfile::tempdir().unwrap();
1352        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1353        add_note(dir.path(), "real-pin-1", "persisted secret", 10).unwrap();
1354        // Fresh calls re-read the file from disk (no in-memory session).
1355        let (_, notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1356        assert_eq!(notes.len(), 1);
1357        assert_eq!(notes[0].body, "persisted secret");
1358    }
1359
1360    // --- S5b: real→decoy key hierarchy + decoy curation ---
1361
1362    #[test]
1363    fn setup_wires_audit_pubkey_and_wrapped_keys() {
1364        let dir = tempfile::tempdir().unwrap();
1365        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1366        let container = load_container(dir.path()).unwrap().unwrap();
1367        // Decoy layer carries the audit PUBLIC key (so a decoy session can seal to it).
1368        let decoy = container.layer_by_role(LayerRole::Decoy).unwrap();
1369        assert!(decoy.audit_pubkey_hex.is_some());
1370        // The audit secret / pubkey are never both readable from the decoy: only the pubkey is here.
1371        assert!(decoy.wrapped_keys.is_empty());
1372        // Real layer wraps BOTH the decoy lane key and the audit secret.
1373        let real = container.layer_by_role(LayerRole::Real).unwrap();
1374        assert!(real.wrapped_key(WK_DECOY_LANE_KEY).is_some());
1375        assert!(real.wrapped_key(WK_AUDIT_SECRET).is_some());
1376    }
1377
1378    #[test]
1379    fn real_session_curates_decoy_without_decoy_pin() {
1380        let dir = tempfile::tempdir().unwrap();
1381        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1382        // The victim, in a safe real session, seeds the decoy — no decoy PIN entered.
1383        real_curate_decoy_add_note(dir.path(), "real-pin-1", "plausible cover note", 20).unwrap();
1384
1385        // The coercer, later, opens the decoy with the duress PIN and sees the seeded note.
1386        let (lane, decoy_notes) = list_notes(dir.path(), "decoy-pin-2").unwrap();
1387        assert_eq!(lane, SanctuaryLane::Decoy);
1388        assert_eq!(decoy_notes.len(), 1);
1389        assert_eq!(decoy_notes[0].body, "plausible cover note");
1390
1391        // The real lane is untouched by curation.
1392        let (_, real_notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1393        assert!(real_notes.is_empty());
1394    }
1395
1396    #[test]
1397    fn curation_and_decoy_pin_writes_coexist_without_nonce_collision() {
1398        let dir = tempfile::tempdir().unwrap();
1399        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1400        real_curate_decoy_add_note(dir.path(), "real-pin-1", "seeded-by-real", 20).unwrap();
1401        add_note(dir.path(), "decoy-pin-2", "written-by-coercer", 21).unwrap();
1402        real_curate_decoy_add_note(dir.path(), "real-pin-1", "seeded-again", 22).unwrap();
1403
1404        let (_, notes) = list_notes(dir.path(), "decoy-pin-2").unwrap();
1405        let bodies: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();
1406        assert_eq!(
1407            bodies,
1408            ["seeded-by-real", "written-by-coercer", "seeded-again"]
1409        );
1410    }
1411
1412    #[test]
1413    fn curation_requires_the_real_pin() {
1414        let dir = tempfile::tempdir().unwrap();
1415        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1416        // The decoy PIN cannot curate the decoy (it must not even be able to detect curation).
1417        assert!(real_curate_decoy_add_note(dir.path(), "decoy-pin-2", "x", 20).is_err());
1418        // A wrong PIN cannot either.
1419        assert!(real_curate_decoy_add_note(dir.path(), "nope-nope", "x", 20).is_err());
1420    }
1421
1422    #[test]
1423    fn audit_pubkey_on_decoy_and_secret_under_real_correspond() {
1424        use qualia_core_db::crypto::sanctuary_audit::{open_sealed, seal_to};
1425
1426        let dir = tempfile::tempdir().unwrap();
1427        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1428        let container = load_container(dir.path()).unwrap().unwrap();
1429
1430        // A decoy session seals a record to the audit pubkey it can see on its own layer.
1431        let decoy = container.layer_by_role(LayerRole::Decoy).unwrap();
1432        let pub_hex = decoy.audit_pubkey_hex.as_ref().unwrap();
1433        let mut audit_pub = [0u8; 32];
1434        audit_pub.copy_from_slice(&hex::decode(pub_hex).unwrap());
1435        let sealed = seal_to(
1436            &audit_pub,
1437            b"coercer wrote a note under duress",
1438            b"branch:test",
1439        )
1440        .unwrap();
1441
1442        // The real lane unwraps the audit secret and opens the record; the decoy never can.
1443        let (_lane, real_key) = open_lane(&container, "real-pin-1", None).unwrap();
1444        let real = container.layer_by_role(LayerRole::Real).unwrap();
1445        let wrapped = real.wrapped_key(WK_AUDIT_SECRET).unwrap();
1446        let real_wrap = wrapping_key_from(&real_key);
1447        let secret_bytes = unwrap_key(
1448            &real_wrap,
1449            &hex::decode(&wrapped.blob_hex).unwrap(),
1450            WRAP_AAD_AUDIT,
1451        )
1452        .unwrap();
1453        let mut audit_secret = [0u8; 32];
1454        audit_secret.copy_from_slice(&secret_bytes);
1455
1456        let opened = open_sealed(&audit_secret, &sealed, b"branch:test").unwrap();
1457        assert_eq!(opened, b"coercer wrote a note under duress");
1458    }
1459
1460    #[test]
1461    fn decoy_key_wrap_is_one_way() {
1462        // The wrapped decoy key must NOT unwrap under a wrapping key derived from the decoy lane.
1463        let dir = tempfile::tempdir().unwrap();
1464        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1465        let container = load_container(dir.path()).unwrap().unwrap();
1466
1467        let (_l, real_key) = open_lane(&container, "real-pin-1", None).unwrap();
1468        // Sanity: it unwraps under the real key.
1469        assert!(unwrap_decoy_material(&container, &real_key).is_ok());
1470
1471        // But not under the decoy key (the decoy cannot reach up).
1472        let (_l2, decoy_key) = open_lane(&container, "decoy-pin-2", None).unwrap();
1473        assert!(unwrap_decoy_material(&container, &decoy_key).is_err());
1474    }
1475
1476    // --- S5c: blind audit append + real-lane review + head anchor ---
1477
1478    #[test]
1479    fn decoy_writes_leave_readable_sealed_trail_for_real_lane() {
1480        let dir = tempfile::tempdir().unwrap();
1481        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1482        add_note(dir.path(), "decoy-pin-2", "coercer wrote this", 10).unwrap();
1483
1484        // The coercer's plaintext note is NOT on disk (the audit payload is a sealed box).
1485        let raw = fs::read(vault_path(dir.path())).unwrap();
1486        let needle = b"coercer wrote this";
1487        assert!(
1488            !raw.windows(needle.len()).any(|w| w == needle),
1489            "decoy body leaked to disk"
1490        );
1491
1492        // The real lane decrypts the trail.
1493        let report = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1494        assert_eq!(report.integrity, AuditIntegrity::Ok);
1495        assert_eq!(report.session_count, 1);
1496        assert!(report.actions.iter().any(|a| a.action == "open_session"));
1497        assert!(report
1498            .actions
1499            .iter()
1500            .any(|a| a.action == "add_note" && a.payload == "coercer wrote this"));
1501    }
1502
1503    #[test]
1504    fn review_requires_the_real_pin() {
1505        let dir = tempfile::tempdir().unwrap();
1506        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1507        add_note(dir.path(), "decoy-pin-2", "x", 10).unwrap();
1508        assert!(review_decoy_activity(dir.path(), "decoy-pin-2").is_err());
1509        assert!(review_decoy_activity(dir.path(), "wrong-pin-zz").is_err());
1510    }
1511
1512    #[test]
1513    fn distinct_sessions_become_distinct_branches() {
1514        let dir = tempfile::tempdir().unwrap();
1515        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1516        add_note_in_session(dir.path(), "decoy-pin-2", "s1 note", 10, "unlock-1").unwrap();
1517        add_note_in_session(dir.path(), "decoy-pin-2", "s2 note", 11, "unlock-2").unwrap();
1518        let report = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1519        assert_eq!(report.session_count, 2);
1520        assert_eq!(report.integrity, AuditIntegrity::Ok);
1521    }
1522
1523    #[test]
1524    fn real_notes_and_decoy_notes_stay_isolated_under_audit() {
1525        // Adding an audit trail must not leak into either lane's note list.
1526        let dir = tempfile::tempdir().unwrap();
1527        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1528        add_note(dir.path(), "decoy-pin-2", "decoy body", 10).unwrap();
1529        add_note(dir.path(), "real-pin-1", "real body", 11).unwrap();
1530
1531        let (_, real_notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1532        assert_eq!(real_notes.len(), 1);
1533        assert_eq!(real_notes[0].body, "real body");
1534        let (_, decoy_notes) = list_notes(dir.path(), "decoy-pin-2").unwrap();
1535        assert_eq!(decoy_notes.len(), 1);
1536        assert_eq!(decoy_notes[0].body, "decoy body");
1537    }
1538
1539    #[test]
1540    fn anchor_flags_truncation_of_witnessed_prefix() {
1541        let dir = tempfile::tempdir().unwrap();
1542        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1543        add_note_in_session(dir.path(), "decoy-pin-2", "note A", 10, "s1").unwrap();
1544        add_note_in_session(dir.path(), "decoy-pin-2", "note B", 11, "s1").unwrap();
1545        // First review witnesses + anchors branch s1 (open_session + 2 add_note = 3 records).
1546        assert_eq!(
1547            review_decoy_activity(dir.path(), "real-pin-1")
1548                .unwrap()
1549                .integrity,
1550            AuditIntegrity::Ok
1551        );
1552
1553        // A coercer with file access drops the last audit record.
1554        let mut container = load_container(dir.path()).unwrap().unwrap();
1555        container.audit_log.pop();
1556        save_container(dir.path(), &container).unwrap();
1557
1558        let report = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1559        assert_eq!(
1560            report.integrity,
1561            AuditIntegrity::WitnessedPrefixAltered {
1562                branch_ref: "s1".into()
1563            }
1564        );
1565    }
1566
1567    #[test]
1568    fn anchor_flags_forged_replacement_of_witnessed_branch() {
1569        let dir = tempfile::tempdir().unwrap();
1570        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1571        add_note_in_session(dir.path(), "decoy-pin-2", "coercer note A", 10, "s1").unwrap();
1572        add_note_in_session(dir.path(), "decoy-pin-2", "coercer note B", 11, "s1").unwrap();
1573        assert_eq!(
1574            review_decoy_activity(dir.path(), "real-pin-1")
1575                .unwrap()
1576                .integrity,
1577            AuditIntegrity::Ok
1578        );
1579
1580        // A coercer wholesale-replaces s1 with a fresh, internally-consistent chain of the same
1581        // length (unkeyed BLAKE3 lets them forge a valid chain) — but the forged head no longer
1582        // matches the real lane's anchor. This is exactly C's finding.
1583        let mut container = load_container(dir.path()).unwrap().unwrap();
1584        let orig_len = container.audit_log.len();
1585        let mut forged = Vec::new();
1586        let mut parent = GENESIS_PARENT;
1587        for i in 0..orig_len {
1588            let rec = AuditRecord::new(
1589                parent,
1590                "s1",
1591                "did:forged",
1592                None,
1593                None,
1594                AuditAction::AddNote,
1595                100 + i as u32,
1596                vec![i as u8],
1597            );
1598            parent = rec.id;
1599            forged.push(rec);
1600        }
1601        container.audit_log = forged;
1602        save_container(dir.path(), &container).unwrap();
1603
1604        let report = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1605        assert_eq!(
1606            report.integrity,
1607            AuditIntegrity::WitnessedPrefixAltered {
1608                branch_ref: "s1".into()
1609            }
1610        );
1611    }
1612
1613    #[test]
1614    fn retention_mode_defaults_to_auto_and_round_trips() {
1615        let dir = tempfile::tempdir().unwrap();
1616        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1617        // Default is auto-archive.
1618        assert_eq!(
1619            get_retention_mode(dir.path(), "real-pin-1").unwrap(),
1620            RetentionMode::AutoArchive
1621        );
1622        // Set → get round-trips, and survives across reopen (it's persisted in the real lane).
1623        set_retention_mode(dir.path(), "real-pin-1", RetentionMode::ManualTriage).unwrap();
1624        assert_eq!(
1625            get_retention_mode(dir.path(), "real-pin-1").unwrap(),
1626            RetentionMode::ManualTriage
1627        );
1628        // A review surfaces the current mode and does not clobber it.
1629        let report = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1630        assert_eq!(report.retention_mode, RetentionMode::ManualTriage);
1631        assert_eq!(
1632            get_retention_mode(dir.path(), "real-pin-1").unwrap(),
1633            RetentionMode::ManualTriage
1634        );
1635    }
1636
1637    #[test]
1638    fn retention_mode_is_real_session_only() {
1639        let dir = tempfile::tempdir().unwrap();
1640        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1641        // The decoy session can neither read nor set the retention policy.
1642        assert!(get_retention_mode(dir.path(), "decoy-pin-2").is_err());
1643        assert!(
1644            set_retention_mode(dir.path(), "decoy-pin-2", RetentionMode::ManualTriage).is_err()
1645        );
1646    }
1647
1648    #[test]
1649    fn retention_setting_survives_decoy_writes_and_reviews() {
1650        let dir = tempfile::tempdir().unwrap();
1651        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1652        set_retention_mode(dir.path(), "real-pin-1", RetentionMode::ManualTriage).unwrap();
1653        // Decoy activity + a real note must not disturb the real-lane retention setting.
1654        add_note(dir.path(), "decoy-pin-2", "coercer note", 10).unwrap();
1655        add_note(dir.path(), "real-pin-1", "real note", 11).unwrap();
1656        review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1657        assert_eq!(
1658            get_retention_mode(dir.path(), "real-pin-1").unwrap(),
1659            RetentionMode::ManualTriage
1660        );
1661    }
1662
1663    #[test]
1664    fn clean_review_is_idempotent_and_stable() {
1665        let dir = tempfile::tempdir().unwrap();
1666        setup_with_iterations(dir.path(), "real-pin-1", "decoy-pin-2", 1_000).unwrap();
1667        add_note(dir.path(), "decoy-pin-2", "one", 10).unwrap();
1668        let a = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1669        let b = review_decoy_activity(dir.path(), "real-pin-1").unwrap();
1670        assert_eq!(a.integrity, AuditIntegrity::Ok);
1671        assert_eq!(b.integrity, AuditIntegrity::Ok);
1672        assert_eq!(a.actions, b.actions);
1673        // Real lane still opens and its notes are intact after anchor writes.
1674        let (_, real_notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1675        assert!(real_notes.is_empty());
1676    }
1677}