Skip to main content

qualia_client_core/
webizen_trust.rs

1//! **Webizen trust store** — user-controlled trust anchors (P1).
2//!
3//! The software provides the *means* to import, enable, disable, and evaluate roots;
4//! it does not silently redefine the OS PKI. Default suggested set is empty until the
5//! principal coins a bundle (AU community roots, etc.).
6//!
7//! Honest scope of this module:
8//! - Persist anchors (PEM certs, DID / front-door identifiers, labels).
9//! - Produce a **trust verdict** for a URL (scheme + store membership + notes).
10//! - Supply PEM material for *our* TLS clients (agent fetch via rustls/reqwest).
11//! - OS WebView cert-override (WebView2 `ServerCertificateErrorDetected`) is a
12//!   platform hook layered in `webizen-desktop`; this store is the policy source.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20pub const TRUST_STORE_FILE: &str = "webizen/trust_store.json";
21/// Suggested catalog (empty until principal curates). Relative to storage or bundled.
22pub const SUGGESTED_CATALOG_FILE: &str = "webizen/suggested_trust_catalog.json";
23pub const CATALOG_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum AnchorKind {
28    /// X.509 root or intermediate PEM.
29    PemRoot,
30    /// Front-door / connection DID or WebID.
31    Did,
32    /// Opaque label the principal trusts by policy (e.g. micro-commons id).
33    PolicyLabel,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TrustAnchor {
38    pub id: String,
39    pub label: String,
40    pub kind: AnchorKind,
41    /// PEM body for PemRoot; DID URI for Did; free text for PolicyLabel.
42    pub material: String,
43    pub enabled: bool,
44    pub notes: String,
45    pub added_unix: u64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct TrustStore {
50    pub version: u32,
51    pub anchors: Vec<TrustAnchor>,
52    /// When true, http(s) sites with no matching custom policy are labelled "os-default".
53    pub defer_unknown_https_to_os: bool,
54}
55
56impl TrustStore {
57    pub fn new() -> Self {
58        Self {
59            version: 1,
60            anchors: Vec::new(),
61            defer_unknown_https_to_os: true,
62        }
63    }
64
65    pub fn path(storage_root: &Path) -> PathBuf {
66        storage_root.join(TRUST_STORE_FILE)
67    }
68
69    pub fn load(storage_root: &Path) -> Self {
70        let p = Self::path(storage_root);
71        match fs::read_to_string(&p) {
72            Ok(s) => serde_json::from_str(&s).unwrap_or_else(|_| Self::new()),
73            Err(_) => Self::new(),
74        }
75    }
76
77    pub fn save(&self, storage_root: &Path) -> Result<(), String> {
78        let p = Self::path(storage_root);
79        if let Some(parent) = p.parent() {
80            fs::create_dir_all(parent).map_err(|e| e.to_string())?;
81        }
82        let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
83        let tmp = p.with_extension("json.tmp");
84        fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
85        fs::rename(&tmp, &p).map_err(|e| e.to_string())
86    }
87
88    pub fn add_pem_root(
89        &mut self,
90        label: &str,
91        pem: &str,
92        notes: &str,
93        now: u64,
94    ) -> Result<TrustAnchor, String> {
95        let pem = pem.trim();
96        if !pem.contains("BEGIN CERTIFICATE") {
97            return Err("expected PEM certificate (BEGIN CERTIFICATE)".into());
98        }
99        let id = format!("pem:{}", short_hash(pem.as_bytes()));
100        if self.anchors.iter().any(|a| a.id == id) {
101            return Err("anchor already present".into());
102        }
103        let a = TrustAnchor {
104            id: id.clone(),
105            label: if label.trim().is_empty() {
106                format!("Root {id}")
107            } else {
108                label.trim().into()
109            },
110            kind: AnchorKind::PemRoot,
111            material: pem.into(),
112            enabled: true,
113            notes: notes.into(),
114            added_unix: now,
115        };
116        self.anchors.push(a.clone());
117        Ok(a)
118    }
119
120    pub fn add_did(
121        &mut self,
122        label: &str,
123        did: &str,
124        notes: &str,
125        now: u64,
126    ) -> Result<TrustAnchor, String> {
127        let did = did.trim();
128        if !did.starts_with("did:") {
129            return Err("DID must start with did:".into());
130        }
131        let id = format!("did:{}", short_hash(did.as_bytes()));
132        if self.anchors.iter().any(|a| a.material == did) {
133            return Err("DID already present".into());
134        }
135        let a = TrustAnchor {
136            id,
137            label: if label.trim().is_empty() {
138                did.to_string()
139            } else {
140                label.trim().into()
141            },
142            kind: AnchorKind::Did,
143            material: did.into(),
144            enabled: true,
145            notes: notes.into(),
146            added_unix: now,
147        };
148        self.anchors.push(a.clone());
149        Ok(a)
150    }
151
152    pub fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<(), String> {
153        let a = self
154            .anchors
155            .iter_mut()
156            .find(|a| a.id == id)
157            .ok_or_else(|| format!("unknown anchor {id}"))?;
158        a.enabled = enabled;
159        Ok(())
160    }
161
162    pub fn remove(&mut self, id: &str) -> bool {
163        let before = self.anchors.len();
164        self.anchors.retain(|a| a.id != id);
165        self.anchors.len() < before
166    }
167
168    /// Enabled PEM roots concatenated for rustls/custom clients.
169    pub fn enabled_pem_bundle(&self) -> String {
170        self.anchors
171            .iter()
172            .filter(|a| a.enabled && a.kind == AnchorKind::PemRoot)
173            .map(|a| a.material.as_str())
174            .collect::<Vec<_>>()
175            .join("\n")
176    }
177
178    pub fn enabled_dids(&self) -> Vec<&str> {
179        self.anchors
180            .iter()
181            .filter(|a| a.enabled && a.kind == AnchorKind::Did)
182            .map(|a| a.material.as_str())
183            .collect()
184    }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TrustVerdict {
189    pub url: String,
190    pub scheme: String,
191    pub host: String,
192    /// os_default | custom_root_available | did_match | local_scheme | untrusted_policy | unknown
193    pub level: String,
194    pub summary: String,
195    pub matching_anchors: Vec<String>,
196    pub notes: Vec<String>,
197}
198
199/// Evaluate how *our* store thinks about this URL (does not replace OS TLS for WebView).
200pub fn evaluate_url(store: &TrustStore, url: &str) -> TrustVerdict {
201    let url = url.trim();
202    let (scheme, host) = parse_scheme_host(url);
203    let mut matching = Vec::new();
204    let mut notes = Vec::new();
205
206    if scheme == "qualia" || scheme == "webizen" {
207        return TrustVerdict {
208            url: url.into(),
209            scheme: scheme.clone(),
210            host: host.clone(),
211            level: "local_scheme".into(),
212            summary: "Local Qualia/Webizen scheme — rendered under your device policy, not public CA trust."
213                .into(),
214            matching_anchors: Vec::new(),
215            notes: vec!["Native protocol handler; not subject to public web PKI.".into()],
216        };
217    }
218
219    // DID anchors: match if host or path contains the DID, or URL is a did:
220    for a in store
221        .anchors
222        .iter()
223        .filter(|a| a.enabled && a.kind == AnchorKind::Did)
224    {
225        if url.contains(&a.material) || host.contains(&a.material) {
226            matching.push(a.label.clone());
227        }
228    }
229    if !matching.is_empty() {
230        return TrustVerdict {
231            url: url.into(),
232            scheme,
233            host,
234            level: "did_match".into(),
235            summary: format!(
236                "Matches DID/front-door anchor(s) in your store: {}.",
237                matching.join(", ")
238            ),
239            matching_anchors: matching,
240            notes: vec![
241                "DID trust is policy-level in Webizen; OS TLS may still apply for https.".into(),
242            ],
243        };
244    }
245
246    let pem_count = store
247        .anchors
248        .iter()
249        .filter(|a| a.enabled && a.kind == AnchorKind::PemRoot)
250        .count();
251    if pem_count > 0 {
252        notes.push(format!(
253            "{pem_count} custom PEM root(s) enabled — used for agent HTTPS fetch; WebView still uses OS store unless platform cert-override is wired."
254        ));
255    }
256
257    if scheme == "https" || scheme == "http" {
258        let level = if store.defer_unknown_https_to_os {
259            "os_default"
260        } else {
261            "untrusted_policy"
262        };
263        let summary = if store.defer_unknown_https_to_os {
264            "No custom DID match. HTTPS validation defers to the OS trust store (WebView)."
265                .to_string()
266        } else {
267            "defer_unknown_https_to_os=false: treat unknown public sites as untrusted by policy."
268                .to_string()
269        };
270        return TrustVerdict {
271            url: url.into(),
272            scheme,
273            host,
274            level: level.into(),
275            summary,
276            matching_anchors: matching,
277            notes,
278        };
279    }
280
281    TrustVerdict {
282        url: url.into(),
283        scheme,
284        host,
285        level: "unknown".into(),
286        summary: "Scheme not classified by the Webizen trust policy.".into(),
287        matching_anchors: matching,
288        notes,
289    }
290}
291
292fn parse_scheme_host(url: &str) -> (String, String) {
293    let u = url.trim();
294    if let Some(rest) = u.strip_prefix("https://") {
295        let host = rest.split(['/', '?', '#']).next().unwrap_or("").to_string();
296        return ("https".into(), host);
297    }
298    if let Some(rest) = u.strip_prefix("http://") {
299        let host = rest.split(['/', '?', '#']).next().unwrap_or("").to_string();
300        return ("http".into(), host);
301    }
302    if let Some(rest) = u.strip_prefix("qualia://") {
303        return ("qualia".into(), rest.to_string());
304    }
305    if let Some(rest) = u.strip_prefix("webizen://") {
306        return ("webizen".into(), rest.to_string());
307    }
308    if u.starts_with("did:") {
309        return ("did".into(), u.to_string());
310    }
311    ("unknown".into(), u.to_string())
312}
313
314fn short_hash(bytes: &[u8]) -> String {
315    let mut h = Sha256::new();
316    h.update(bytes);
317    let d = h.finalize();
318    hex::encode(&d[..8])
319}
320
321// ── Suggested trust catalog (T0/T1) — means only; no invented roots ──────────
322
323/// One **suggested** anchor. Never auto-enabled unless `enabled_by_default` is true
324/// (must stay false until the principal explicitly curates a default).
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SuggestedAnchor {
327    pub id: String,
328    pub label: String,
329    /// Jurisdiction or community tag (e.g. "AU", "micro-commons") — free text.
330    #[serde(default)]
331    pub jurisdiction: String,
332    pub kind: AnchorKind,
333    /// Inline PEM / DID / label material. Prefer inline for small catalogs.
334    #[serde(default)]
335    pub material: String,
336    /// Optional path relative to catalog dir (e.g. `roots/example.pem`). Loaded if material empty.
337    #[serde(default)]
338    pub material_path: Option<String>,
339    /// Must be false for ship defaults until principal curates.
340    #[serde(default)]
341    pub enabled_by_default: bool,
342    #[serde(default)]
343    pub notes: String,
344    #[serde(default)]
345    pub source_url: Option<String>,
346    #[serde(default)]
347    pub license: Option<String>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct SuggestedTrustCatalog {
352    pub version: u32,
353    #[serde(default)]
354    pub description: String,
355    #[serde(default)]
356    pub entries: Vec<SuggestedAnchor>,
357}
358
359impl Default for SuggestedTrustCatalog {
360    fn default() -> Self {
361        Self {
362            version: CATALOG_VERSION,
363            description: "Empty suggested trust catalog. Principal curates content; software provides means only.".into(),
364            entries: Vec::new(),
365        }
366    }
367}
368
369impl SuggestedTrustCatalog {
370    pub fn empty() -> Self {
371        Self::default()
372    }
373
374    /// Load catalog from JSON bytes. Malformed → Err (fail closed).
375    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, String> {
376        if bytes.is_empty() {
377            return Ok(Self::empty());
378        }
379        serde_json::from_slice(bytes).map_err(|e| format!("suggested catalog parse: {e}"))
380    }
381
382    pub fn from_json_str(s: &str) -> Result<Self, String> {
383        Self::from_json_bytes(s.as_bytes())
384    }
385
386    /// Load from a file path; missing file → empty catalog (not an error).
387    pub fn load_path(path: &Path) -> Result<Self, String> {
388        match fs::read(path) {
389            Ok(b) => Self::from_json_bytes(&b),
390            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::empty()),
391            Err(e) => Err(e.to_string()),
392        }
393    }
394
395    /// Prefer storage override, else bundled path next to catalog default location.
396    pub fn load_for_storage(storage_root: &Path) -> Result<Self, String> {
397        let storage_cat = storage_root.join(SUGGESTED_CATALOG_FILE);
398        if storage_cat.is_file() {
399            return Self::load_path(&storage_cat);
400        }
401        // Bundled empty catalog (repo / package).
402        if let Some(bundled) = bundled_catalog_path() {
403            return Self::load_path(&bundled);
404        }
405        Ok(Self::empty())
406    }
407
408    pub fn get(&self, id: &str) -> Option<&SuggestedAnchor> {
409        self.entries.iter().find(|e| e.id == id)
410    }
411
412    /// Resolve material for an entry (inline or file relative to `base_dir`).
413    pub fn resolve_material(
414        &self,
415        entry: &SuggestedAnchor,
416        base_dir: &Path,
417    ) -> Result<String, String> {
418        let inline = entry.material.trim();
419        if !inline.is_empty() {
420            return Ok(inline.to_string());
421        }
422        if let Some(rel) = entry.material_path.as_deref() {
423            let p = base_dir.join(rel);
424            return fs::read_to_string(&p).map_err(|e| format!("read {}: {e}", p.display()));
425        }
426        Err(format!("suggested anchor {} has no material", entry.id))
427    }
428}
429
430/// Path to repo/package empty catalog when present.
431pub fn bundled_catalog_path() -> Option<PathBuf> {
432    // Desktop: next to exe resources or relative to CARGO_MANIFEST of client-core.
433    let candidates = [
434        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../bundled/trust/catalog.json"),
435        PathBuf::from("bundled/trust/catalog.json"),
436    ];
437    candidates.into_iter().find(|p| p.is_file())
438}
439
440/// Import a suggested entry into the live store.
441/// `force_enabled` overrides `enabled_by_default` (UI "Enable now").
442pub fn import_suggested_into_store(
443    store: &mut TrustStore,
444    catalog: &SuggestedTrustCatalog,
445    entry_id: &str,
446    base_dir: &Path,
447    now: u64,
448    force_enabled: bool,
449) -> Result<TrustAnchor, String> {
450    let entry = catalog
451        .get(entry_id)
452        .ok_or_else(|| format!("unknown suggested id {entry_id}"))?;
453    let material = catalog.resolve_material(entry, base_dir)?;
454    let enabled = force_enabled || entry.enabled_by_default;
455    match entry.kind {
456        AnchorKind::PemRoot => {
457            let a = store.add_pem_root(&entry.label, &material, &entry.notes, now)?;
458            if !enabled {
459                let _ = store.set_enabled(&a.id, false);
460            }
461            Ok(store
462                .anchors
463                .iter()
464                .find(|x| x.id == a.id)
465                .cloned()
466                .unwrap_or(a))
467        }
468        AnchorKind::Did => {
469            let a = store.add_did(&entry.label, &material, &entry.notes, now)?;
470            if !enabled {
471                let _ = store.set_enabled(&a.id, false);
472            }
473            Ok(store
474                .anchors
475                .iter()
476                .find(|x| x.id == a.id)
477                .cloned()
478                .unwrap_or(a))
479        }
480        AnchorKind::PolicyLabel => {
481            let id = format!("policy:{}", short_hash(material.as_bytes()));
482            if store.anchors.iter().any(|a| a.id == id) {
483                return Err("anchor already present".into());
484            }
485            let a = TrustAnchor {
486                id: id.clone(),
487                label: entry.label.clone(),
488                kind: AnchorKind::PolicyLabel,
489                material,
490                enabled,
491                notes: entry.notes.clone(),
492                added_unix: now,
493            };
494            store.anchors.push(a.clone());
495            Ok(a)
496        }
497    }
498}
499
500/// Host / session / chain policy decision for cert-override (swarm-2).
501///
502/// Security model: host-pin (A) by default path; chain vs **enabled** PEMs (B) only
503/// after cryptographic verify; never auto-allow solely because PEMs exist.
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum CertOverrideDecision {
506    /// No policy match — deny by default.
507    Deny,
508    /// Soft sticky deny (principal chose Deny and asked not to re-prompt).
509    SoftDenied,
510    /// Explicit host allow entry (policy label `host-allow:example.com`) — A.
511    AllowHostPinned,
512    /// Session allow-once (process memory; not a permanent pin).
513    AllowSessionOnce,
514    /// PEM roots enabled: platform **must** call chain verify; without cert material → deny.
515    /// This is **not** an automatic allow.
516    CandidateCustomRoots,
517    /// Chain verified against enabled PEMs (B accepted).
518    AllowChainVerified,
519    /// SPKI pin matched.
520    AllowSpkiPinned,
521}
522
523/// SPKI pin material: `spki-pin:<host>:<sha256hex>`
524pub fn spki_pin_material(host: &str, spki_sha256_hex: &str) -> String {
525    format!(
526        "spki-pin:{}:{}",
527        host.trim().to_ascii_lowercase(),
528        spki_sha256_hex.trim().to_ascii_lowercase()
529    )
530}
531
532/// Soft-deny material: `host-deny:<host>`
533pub fn host_deny_material(host: &str) -> String {
534    format!("host-deny:{}", host.trim().to_ascii_lowercase())
535}
536
537/// Policy for ServerCertificateErrorDetected handlers (store only — no session).
538/// Prefer [`cert_override_decision_full`] for production hooks.
539pub fn cert_override_decision(store: &TrustStore, host: &str) -> CertOverrideDecision {
540    cert_override_decision_full(store, host, false, false, None)
541}
542
543/// Full policy: store + optional session allow-once + optional chain verify result.
544///
545/// `session_allow` — process-local allow-once for this host.  
546/// `soft_denied` — sticky deny without re-prompt.  
547/// `chain_verified` — `Some(true/false)` after B path crypto; `None` if no leaf PEM available.
548pub fn cert_override_decision_full(
549    store: &TrustStore,
550    host: &str,
551    session_allow: bool,
552    soft_denied: bool,
553    chain_verified: Option<bool>,
554) -> CertOverrideDecision {
555    let host = host.trim().to_ascii_lowercase();
556    if host.is_empty() {
557        return CertOverrideDecision::Deny;
558    }
559    if soft_denied {
560        return CertOverrideDecision::SoftDenied;
561    }
562    // Soft-deny in store
563    for a in store.anchors.iter().filter(|a| a.enabled) {
564        if a.kind == AnchorKind::PolicyLabel {
565            let m = a.material.trim().to_ascii_lowercase();
566            if m == host_deny_material(&host) {
567                return CertOverrideDecision::SoftDenied;
568            }
569        }
570    }
571    // A: host pin
572    for a in store.anchors.iter().filter(|a| a.enabled) {
573        if a.kind == AnchorKind::PolicyLabel {
574            let m = a.material.trim().to_ascii_lowercase();
575            if m == format!("host-allow:{host}") || m == host {
576                return CertOverrideDecision::AllowHostPinned;
577            }
578        }
579    }
580    // Session allow-once (escape hatch; not permanent)
581    if session_allow {
582        return CertOverrideDecision::AllowSessionOnce;
583    }
584    // B: chain verify result
585    if let Some(ok) = chain_verified {
586        if ok {
587            return CertOverrideDecision::AllowChainVerified;
588        }
589        // verified false → fall through to candidate/deny
590    }
591    let pem_n = store
592        .anchors
593        .iter()
594        .filter(|a| a.enabled && a.kind == AnchorKind::PemRoot)
595        .count();
596    if pem_n > 0 {
597        // Without a successful chain_verified=true, do **not** allow.
598        // Signal that B could apply if platform supplies leaf PEM.
599        return CertOverrideDecision::CandidateCustomRoots;
600    }
601    CertOverrideDecision::Deny
602}
603
604/// Whether the decision should allow the WebView TLS connection.
605pub fn decision_allows(d: CertOverrideDecision) -> bool {
606    matches!(
607        d,
608        CertOverrideDecision::AllowHostPinned
609            | CertOverrideDecision::AllowSessionOnce
610            | CertOverrideDecision::AllowChainVerified
611            | CertOverrideDecision::AllowSpkiPinned
612    )
613}
614
615/// Audit-friendly reason string.
616pub fn decision_reason(d: CertOverrideDecision) -> &'static str {
617    match d {
618        CertOverrideDecision::Deny => "deny",
619        CertOverrideDecision::SoftDenied => "soft_deny",
620        CertOverrideDecision::AllowHostPinned => "host_pin",
621        CertOverrideDecision::AllowSessionOnce => "session_once",
622        CertOverrideDecision::CandidateCustomRoots => "candidate_custom_roots_need_verify",
623        CertOverrideDecision::AllowChainVerified => "chain_verified",
624        CertOverrideDecision::AllowSpkiPinned => "spki_pin",
625    }
626}
627
628// ── Signed suggested catalog (principal key) ─────────────────────────────────
629
630/// Envelope for a suggested catalog signed by the principal.
631#[derive(Debug, Clone, Serialize, Deserialize)]
632pub struct SignedSuggestedCatalog {
633    pub catalog: SuggestedTrustCatalog,
634    /// Ed25519 signature over `canonical_catalog_bytes` (hex).
635    pub signature_hex: String,
636    /// Ed25519 public key (32 bytes hex) of the signing principal.
637    pub public_key_hex: String,
638    #[serde(default)]
639    pub algorithm: String,
640}
641
642/// Canonical bytes for signing: JSON of catalog with sorted keys (serde_json value dump).
643pub fn catalog_signing_payload(catalog: &SuggestedTrustCatalog) -> Result<Vec<u8>, String> {
644    // Deterministic: pretty=false, field order as struct definition.
645    serde_json::to_vec(catalog).map_err(|e| e.to_string())
646}
647
648/// Verify Ed25519 signature over the catalog payload.
649pub fn verify_signed_catalog(envelope: &SignedSuggestedCatalog) -> Result<(), String> {
650    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
651    let algo = envelope.algorithm.trim().to_ascii_lowercase();
652    if !algo.is_empty() && algo != "ed25519" {
653        return Err(format!(
654            "unsupported catalog algorithm '{algo}' (ed25519 only in this build)"
655        ));
656    }
657    let pk_bytes =
658        hex::decode(envelope.public_key_hex.trim()).map_err(|e| format!("public_key_hex: {e}"))?;
659    if pk_bytes.len() != 32 {
660        return Err("public_key_hex must be 32 bytes".into());
661    }
662    let mut pk_arr = [0u8; 32];
663    pk_arr.copy_from_slice(&pk_bytes);
664    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| format!("verifying key: {e}"))?;
665    let sig_bytes =
666        hex::decode(envelope.signature_hex.trim()).map_err(|e| format!("signature_hex: {e}"))?;
667    if sig_bytes.len() != 64 {
668        return Err("signature must be 64 bytes".into());
669    }
670    let mut sig_arr = [0u8; 64];
671    sig_arr.copy_from_slice(&sig_bytes);
672    let sig = Signature::from_bytes(&sig_arr);
673    let payload = catalog_signing_payload(&envelope.catalog)?;
674    vk.verify(&payload, &sig)
675        .map_err(|_| "catalog signature invalid".to_string())?;
676    Ok(())
677}
678
679/// Load signed catalog from path; unsigned plain catalog still loads via SuggestedTrustCatalog.
680pub fn load_signed_catalog_path(path: &Path) -> Result<SuggestedTrustCatalog, String> {
681    let bytes = fs::read(path).map_err(|e| e.to_string())?;
682    // Try signed envelope first
683    if let Ok(env) = serde_json::from_slice::<SignedSuggestedCatalog>(&bytes) {
684        if !env.signature_hex.is_empty() {
685            verify_signed_catalog(&env)?;
686            return Ok(env.catalog);
687        }
688    }
689    SuggestedTrustCatalog::from_json_bytes(&bytes)
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn pem_and_did_roundtrip() {
698        let dir = tempfile::tempdir().unwrap();
699        let mut s = TrustStore::new();
700        s.add_did("Front door", "did:web:example.org", "test", 1)
701            .unwrap();
702        let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
703        s.add_pem_root("Test root", pem, "", 2).unwrap();
704        s.save(dir.path()).unwrap();
705        let loaded = TrustStore::load(dir.path());
706        assert_eq!(loaded.anchors.len(), 2);
707        let v = evaluate_url(&loaded, "https://example.org/x?ref=did:web:example.org");
708        assert_eq!(v.level, "did_match");
709        let v2 = evaluate_url(&loaded, "https://google.com/");
710        assert_eq!(v2.level, "os_default");
711        let v3 = evaluate_url(&loaded, "qualia://webid/did:q42:local");
712        assert_eq!(v3.level, "local_scheme");
713    }
714
715    #[test]
716    fn add_did_then_remove_reverts_verdict() {
717        let dir = tempfile::tempdir().unwrap();
718        let mut s = TrustStore::new();
719        let a = s
720            .add_did("Peer", "did:web:trusted.example", "", 10)
721            .unwrap();
722        s.save(dir.path()).unwrap();
723        let loaded = TrustStore::load(dir.path());
724        let url = "https://site.test/?id=did:web:trusted.example";
725        assert_eq!(evaluate_url(&loaded, url).level, "did_match");
726        let mut s2 = TrustStore::load(dir.path());
727        assert!(s2.remove(&a.id));
728        s2.save(dir.path()).unwrap();
729        let loaded2 = TrustStore::load(dir.path());
730        assert_eq!(evaluate_url(&loaded2, url).level, "os_default");
731    }
732
733    #[test]
734    fn disable_anchor_skips_match() {
735        let dir = tempfile::tempdir().unwrap();
736        let mut s = TrustStore::new();
737        let a = s.add_did("Peer", "did:web:off.example", "", 1).unwrap();
738        s.set_enabled(&a.id, false).unwrap();
739        s.save(dir.path()).unwrap();
740        let loaded = TrustStore::load(dir.path());
741        let v = evaluate_url(&loaded, "https://x/?did:web:off.example");
742        assert_eq!(v.level, "os_default");
743    }
744
745    #[test]
746    fn empty_catalog_loads() {
747        let c = SuggestedTrustCatalog::from_json_str(
748            r#"{"version":1,"description":"test","entries":[]}"#,
749        )
750        .unwrap();
751        assert!(c.entries.is_empty());
752        assert_eq!(SuggestedTrustCatalog::empty().entries.len(), 0);
753    }
754
755    #[test]
756    fn malformed_catalog_fails_closed() {
757        assert!(SuggestedTrustCatalog::from_json_str("{not json").is_err());
758    }
759
760    #[test]
761    fn import_suggested_did_disabled_by_default() {
762        let cat = SuggestedTrustCatalog {
763            version: 1,
764            description: "fixture".into(),
765            entries: vec![SuggestedAnchor {
766                id: "sug-did-1".into(),
767                label: "Suggested peer".into(),
768                jurisdiction: "test".into(),
769                kind: AnchorKind::Did,
770                material: "did:web:suggested.example".into(),
771                material_path: None,
772                enabled_by_default: false,
773                notes: "fixture only".into(),
774                source_url: None,
775                license: None,
776            }],
777        };
778        let mut store = TrustStore::new();
779        let a =
780            import_suggested_into_store(&mut store, &cat, "sug-did-1", Path::new("."), 1, false)
781                .unwrap();
782        assert!(!a.enabled);
783        assert_eq!(
784            cert_override_decision(&store, "evil.example"),
785            CertOverrideDecision::Deny
786        );
787    }
788
789    #[test]
790    fn cert_override_host_pin() {
791        let mut s = TrustStore::new();
792        s.anchors.push(TrustAnchor {
793            id: "policy:host".into(),
794            label: "Pin".into(),
795            kind: AnchorKind::PolicyLabel,
796            material: "host-allow:intranet.local".into(),
797            enabled: true,
798            notes: "".into(),
799            added_unix: 1,
800        });
801        assert_eq!(
802            cert_override_decision(&s, "intranet.local"),
803            CertOverrideDecision::AllowHostPinned
804        );
805        assert_eq!(
806            cert_override_decision(&s, "other.local"),
807            CertOverrideDecision::Deny
808        );
809    }
810
811    #[test]
812    fn pem_roots_alone_do_not_allow() {
813        let mut s = TrustStore::new();
814        s.anchors.push(TrustAnchor {
815            id: "pem:x".into(),
816            label: "x".into(),
817            kind: AnchorKind::PemRoot,
818            material: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n".into(),
819            enabled: true,
820            notes: "".into(),
821            added_unix: 1,
822        });
823        // Swarm-2: PEMs present → candidate only, never auto-allow without chain verify.
824        assert_eq!(
825            cert_override_decision(&s, "intranet.local"),
826            CertOverrideDecision::CandidateCustomRoots
827        );
828        assert!(!decision_allows(CertOverrideDecision::CandidateCustomRoots));
829        assert!(decision_allows(CertOverrideDecision::AllowHostPinned));
830        let d = cert_override_decision_full(&s, "h.test", false, false, Some(true));
831        assert_eq!(d, CertOverrideDecision::AllowChainVerified);
832        assert!(decision_allows(d));
833    }
834
835    #[test]
836    fn soft_deny_and_session_once() {
837        let s = TrustStore::new();
838        assert_eq!(
839            cert_override_decision_full(&s, "x.test", false, true, None),
840            CertOverrideDecision::SoftDenied
841        );
842        assert_eq!(
843            cert_override_decision_full(&s, "x.test", true, false, None),
844            CertOverrideDecision::AllowSessionOnce
845        );
846    }
847
848    #[test]
849    fn signed_catalog_roundtrip_ed25519() {
850        use ed25519_dalek::{Signer, SigningKey};
851        let rng_bytes = [7u8; 32];
852        let sk = SigningKey::from_bytes(&rng_bytes);
853        let vk = sk.verifying_key();
854        let catalog = SuggestedTrustCatalog {
855            version: 1,
856            description: "signed empty".into(),
857            entries: vec![],
858        };
859        let payload = catalog_signing_payload(&catalog).unwrap();
860        let sig = sk.sign(&payload);
861        let env = SignedSuggestedCatalog {
862            catalog,
863            signature_hex: hex::encode(sig.to_bytes()),
864            public_key_hex: hex::encode(vk.as_bytes()),
865            algorithm: "ed25519".into(),
866        };
867        verify_signed_catalog(&env).unwrap();
868        // Tamper
869        let mut bad = env.clone();
870        bad.catalog.description = "tampered".into();
871        assert!(verify_signed_catalog(&bad).is_err());
872    }
873}