1#![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
53pub const SANCTUARY_VAULT_FILE: &str = "wellfair/sanctuary_vault.cbor";
55const ALGO: SanctuaryAeadAlgorithm = SanctuaryAeadAlgorithm::Aes256Gcm;
56const VERIFIER_MAGIC: &[u8] = b"WELLFAIR-SANCTUARY-VERIFIER-v1";
57const VERIFIER_CHUNK: u64 = u64::MAX;
59const MIN_PIN_LEN: usize = 6;
62
63const WK_DECOY_LANE_KEY: &str = "decoy_lane_key";
71const WK_AUDIT_SECRET: &str = "audit_secret";
73const WRAP_AAD_DECOY: &[u8] = b"q42:sanctuary:wrap:decoy-lane-key:v1";
75const WRAP_AAD_AUDIT: &[u8] = b"q42:sanctuary:wrap:audit-secret:v1";
77const KEY_MATERIAL_BYTES: usize = 48;
79
80const DECOY_ACTOR_DID: &str = "did:qualia:sanctuary:decoy-session";
84const DEFAULT_DECOY_BRANCH: &str = "decoy:default";
88
89fn 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
115fn 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#[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 fn role(self) -> LayerRole {
145 match self {
146 SanctuaryLane::Real => LayerRole::Real,
147 SanctuaryLane::Decoy => LayerRole::Decoy,
148 }
149 }
150
151 fn layer_id(self) -> &'static str {
153 match self {
154 SanctuaryLane::Real => "real",
155 SanctuaryLane::Decoy => "decoy:0",
156 }
157 }
158}
159
160#[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
191 retention: Option<RetentionMode>,
192}
193
194fn 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::Uuid::new_v4().into_bytes()
213}
214
215fn 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 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
252fn 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
267fn 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
275fn 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
290fn 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
307fn 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
317fn 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
345fn 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
359fn 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
391fn 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
439pub fn is_configured(root: impl AsRef<Path>) -> bool {
441 vault_path(root.as_ref()).exists()
442}
443
444pub 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#[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
470fn 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 let audit = AuditKeypair::generate().map_err(|e| format!("audit keypair: {e:?}"))?;
496 decoy.audit_pubkey_hex = Some(hex::encode(audit.public));
497
498 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 let container = VaultContainerV2::new(vec![real, decoy], pepper.is_some(), vault_id)?;
518 save_container(root, &container)
519}
520
521fn 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
540pub 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 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
584fn 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
607pub 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
616pub 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 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
647pub 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 sanctuary_keychain::store_pepper(vault_id, &pepper)?;
670 }
671 Ok(lane)
672}
673
674pub 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
696pub 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
710pub 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
724pub 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 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 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
774fn 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
794fn 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
802fn 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
825fn 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#[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(¤t) {
891 candidate
892 } else {
893 current
894 }
895}
896
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899pub struct DecoyActionView {
900 pub branch_ref: String,
901 pub action: String,
903 pub actor_did: String,
904 pub unix: u32,
905 pub payload: String,
907}
908
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911pub struct DecoyActivityReport {
912 pub integrity: AuditIntegrity,
913 pub session_count: usize,
918 pub actions: Vec<DecoyActionView>,
919 pub retention_mode: RetentionMode,
921}
922
923pub 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 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 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 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
1066pub 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
1080pub 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 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 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 assert!(container.audit_log.is_empty());
1192 assert!(vault_path(dir.path()).to_string_lossy().ends_with(".cbor"));
1194 }
1195
1196 #[test]
1197 fn effective_secret_folds_pepper_deterministically() {
1198 assert_eq!(effective_secret("pin", None), b"pin".to_vec());
1200 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 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 let (lane, _k) = open_lane(&container, "real-pin-1", Some(&pepper)).unwrap();
1229 assert_eq!(lane, SanctuaryLane::Real);
1230
1231 assert!(open_lane(&container, "real-pin-1", None).is_err());
1233 assert!(open_lane(&container, "real-pin-1", Some(&[9u8; 32])).is_err());
1235 }
1236
1237 #[test]
1238 fn unwrapped_vault_needs_no_pepper() {
1239 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 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 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 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 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 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 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 #[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 let decoy = container.layer_by_role(LayerRole::Decoy).unwrap();
1369 assert!(decoy.audit_pubkey_hex.is_some());
1370 assert!(decoy.wrapped_keys.is_empty());
1372 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 real_curate_decoy_add_note(dir.path(), "real-pin-1", "plausible cover note", 20).unwrap();
1384
1385 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 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 assert!(real_curate_decoy_add_note(dir.path(), "decoy-pin-2", "x", 20).is_err());
1418 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 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 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 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 assert!(unwrap_decoy_material(&container, &real_key).is_ok());
1470
1471 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 #[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 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 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 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 assert_eq!(
1547 review_decoy_activity(dir.path(), "real-pin-1")
1548 .unwrap()
1549 .integrity,
1550 AuditIntegrity::Ok
1551 );
1552
1553 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 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 assert_eq!(
1619 get_retention_mode(dir.path(), "real-pin-1").unwrap(),
1620 RetentionMode::AutoArchive
1621 );
1622 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 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 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 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 let (_, real_notes) = list_notes(dir.path(), "real-pin-1").unwrap();
1675 assert!(real_notes.is_empty());
1676 }
1677}