1use 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";
21pub 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 PemRoot,
30 Did,
32 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 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 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 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 pub level: String,
194 pub summary: String,
195 pub matching_anchors: Vec<String>,
196 pub notes: Vec<String>,
197}
198
199pub 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct SuggestedAnchor {
327 pub id: String,
328 pub label: String,
329 #[serde(default)]
331 pub jurisdiction: String,
332 pub kind: AnchorKind,
333 #[serde(default)]
335 pub material: String,
336 #[serde(default)]
338 pub material_path: Option<String>,
339 #[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 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 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 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 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 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
430pub fn bundled_catalog_path() -> Option<PathBuf> {
432 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
440pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum CertOverrideDecision {
506 Deny,
508 SoftDenied,
510 AllowHostPinned,
512 AllowSessionOnce,
514 CandidateCustomRoots,
517 AllowChainVerified,
519 AllowSpkiPinned,
521}
522
523pub 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
532pub fn host_deny_material(host: &str) -> String {
534 format!("host-deny:{}", host.trim().to_ascii_lowercase())
535}
536
537pub fn cert_override_decision(store: &TrustStore, host: &str) -> CertOverrideDecision {
540 cert_override_decision_full(store, host, false, false, None)
541}
542
543pub 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 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 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 if session_allow {
582 return CertOverrideDecision::AllowSessionOnce;
583 }
584 if let Some(ok) = chain_verified {
586 if ok {
587 return CertOverrideDecision::AllowChainVerified;
588 }
589 }
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 return CertOverrideDecision::CandidateCustomRoots;
600 }
601 CertOverrideDecision::Deny
602}
603
604pub 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
615pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
632pub struct SignedSuggestedCatalog {
633 pub catalog: SuggestedTrustCatalog,
634 pub signature_hex: String,
636 pub public_key_hex: String,
638 #[serde(default)]
639 pub algorithm: String,
640}
641
642pub fn catalog_signing_payload(catalog: &SuggestedTrustCatalog) -> Result<Vec<u8>, String> {
644 serde_json::to_vec(catalog).map_err(|e| e.to_string())
646}
647
648pub 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
679pub fn load_signed_catalog_path(path: &Path) -> Result<SuggestedTrustCatalog, String> {
681 let bytes = fs::read(path).map_err(|e| e.to_string())?;
682 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 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 let mut bad = env.clone();
870 bad.catalog.description = "tampered".into();
871 assert!(verify_signed_catalog(&bad).is_err());
872 }
873}