Skip to main content

qualia_client_core/wellfair/
sanctuary.rs

1//! Sanctuary vault state — setup, lock, decoy session (SAF-01..20; no destructive PIN).
2
3use std::fs;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use super::journal::JournalEntry;
10
11pub const SANCTUARY_PREFS_FILE: &str = "wellfair/sanctuary_prefs.json";
12
13/// Journal kinds hidden while Sanctuary is locked (including decoy session).
14pub const SANCTUARY_PROTECTED_KINDS: &[&str] = &["therapy_note", "sanctuary_note", "welfare_case"];
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct SanctuaryPrefs {
18    pub enabled: bool,
19    pub locked: bool,
20    pub decoy_session: bool,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub real_pin_hash_hex: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub decoy_pin_hash_hex: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub armed_at_unix: Option<u32>,
27}
28
29impl Default for SanctuaryPrefs {
30    fn default() -> Self {
31        Self {
32            enabled: false,
33            locked: false,
34            decoy_session: false,
35            real_pin_hash_hex: None,
36            decoy_pin_hash_hex: None,
37            armed_at_unix: None,
38        }
39    }
40}
41
42pub fn hash_pin(pin: &str) -> String {
43    hex::encode(Sha256::digest(pin.as_bytes()))
44}
45
46pub fn load_prefs(storage_root: impl AsRef<Path>) -> SanctuaryPrefs {
47    let path = storage_root.as_ref().join(SANCTUARY_PREFS_FILE);
48    if !path.exists() {
49        return SanctuaryPrefs::default();
50    }
51    fs::read_to_string(&path)
52        .ok()
53        .and_then(|s| serde_json::from_str(&s).ok())
54        .unwrap_or_default()
55}
56
57pub fn save_prefs(storage_root: impl AsRef<Path>, prefs: &SanctuaryPrefs) -> std::io::Result<()> {
58    let path = storage_root.as_ref().join(SANCTUARY_PREFS_FILE);
59    if let Some(parent) = path.parent() {
60        fs::create_dir_all(parent)?;
61    }
62    let json =
63        serde_json::to_string_pretty(prefs).map_err(|e| std::io::Error::other(e.to_string()))?;
64    fs::write(&path, json)
65}
66
67pub fn setup_sanctuary(
68    storage_root: impl AsRef<Path>,
69    real_pin: &str,
70    decoy_pin: &str,
71    now_unix: u32,
72) -> Result<SanctuaryPrefs, String> {
73    if real_pin.len() < 4 || decoy_pin.len() < 4 {
74        return Err("PIN must be at least 4 characters".into());
75    }
76    if real_pin == decoy_pin {
77        return Err("Decoy PIN must differ from the real unlock PIN".into());
78    }
79    let prefs = SanctuaryPrefs {
80        enabled: true,
81        locked: false,
82        decoy_session: false,
83        real_pin_hash_hex: Some(hash_pin(real_pin)),
84        decoy_pin_hash_hex: Some(hash_pin(decoy_pin)),
85        armed_at_unix: Some(now_unix),
86    };
87    save_prefs(storage_root, &prefs).map_err(|e| e.to_string())?;
88    Ok(prefs)
89}
90
91pub fn lock_sanctuary(storage_root: impl AsRef<Path>) -> Result<SanctuaryPrefs, String> {
92    let mut prefs = load_prefs(&storage_root);
93    if !prefs.enabled {
94        return Err("Sanctuary is not set up".into());
95    }
96    prefs.locked = true;
97    prefs.decoy_session = false;
98    save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
99    Ok(prefs)
100}
101
102pub fn unlock_sanctuary(
103    storage_root: impl AsRef<Path>,
104    pin: &str,
105) -> Result<SanctuaryPrefs, String> {
106    let mut prefs = load_prefs(&storage_root);
107    if !prefs.enabled {
108        return Err("Sanctuary is not set up".into());
109    }
110    let hash = hash_pin(pin);
111    let real = prefs.real_pin_hash_hex.as_deref();
112    let decoy = prefs.decoy_pin_hash_hex.as_deref();
113    if Some(hash.as_str()) == real {
114        prefs.locked = false;
115        prefs.decoy_session = false;
116        save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
117        return Ok(prefs);
118    }
119    if Some(hash.as_str()) == decoy {
120        prefs.locked = true;
121        prefs.decoy_session = true;
122        save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
123        return Ok(prefs);
124    }
125    Err("Incorrect PIN".into())
126}
127
128pub fn is_sanctuary_protected_kind(kind: &str) -> bool {
129    SANCTUARY_PROTECTED_KINDS.contains(&kind)
130}
131
132pub fn apply_sanctuary_projection(
133    prefs: &SanctuaryPrefs,
134    entries: Vec<JournalEntry>,
135) -> Vec<JournalEntry> {
136    if !prefs.enabled || !prefs.locked {
137        return entries;
138    }
139    entries
140        .into_iter()
141        .filter(|e| !is_sanctuary_protected_kind(&e.kind))
142        .collect()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::wellfair::journal::JournalEntry;
149
150    fn entry(kind: &str) -> JournalEntry {
151        JournalEntry {
152            id: format!("urn:test:{kind}"),
153            kind: kind.into(),
154            asserted_time_unix: 0,
155            evidence_type: "SelfReported".into(),
156            sensitivity: "Classified".into(),
157            blob_hash: None,
158            source: "test".into(),
159            committed_unix: 0,
160            summary: None,
161        }
162    }
163
164    #[test]
165    fn locked_sanctuary_hides_protected_kinds() {
166        let prefs = SanctuaryPrefs {
167            enabled: true,
168            locked: true,
169            decoy_session: false,
170            ..Default::default()
171        };
172        let rows = vec![entry("weight"), entry("therapy_note"), entry("life_event")];
173        let out = apply_sanctuary_projection(&prefs, rows);
174        assert_eq!(out.len(), 2);
175        assert!(out.iter().all(|e| !is_sanctuary_protected_kind(&e.kind)));
176    }
177
178    #[test]
179    fn decoy_pin_keeps_locked_with_decoy_flag() {
180        let dir = tempfile::tempdir().unwrap();
181        setup_sanctuary(dir.path(), "real-pin-1", "decoy-pin-2", 10).unwrap();
182        lock_sanctuary(dir.path()).unwrap();
183        let prefs = unlock_sanctuary(dir.path(), "decoy-pin-2").unwrap();
184        assert!(prefs.locked);
185        assert!(prefs.decoy_session);
186    }
187
188    #[test]
189    fn real_pin_unlocks() {
190        let dir = tempfile::tempdir().unwrap();
191        setup_sanctuary(dir.path(), "real-pin-1", "decoy-pin-2", 10).unwrap();
192        lock_sanctuary(dir.path()).unwrap();
193        let prefs = unlock_sanctuary(dir.path(), "real-pin-1").unwrap();
194        assert!(!prefs.locked);
195        assert!(!prefs.decoy_session);
196    }
197}