Skip to main content

qualia_client_core/
webizen_x509.rs

1//! Bounded X.509 helpers for Webizen trust (swarm-2 Track C / D6).
2//!
3//! Hard caps: no full root program, no CT, no OCSP, no MDM.
4//! Agents must never invent PEMs — only verify principal-enabled material.
5
6use sha2::{Digest, Sha256};
7use x509_parser::prelude::*;
8
9use crate::webizen_trust::TrustStore;
10
11/// Result of a bounded path check against enabled PEM roots.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PathVerifyResult {
14    pub accepted: bool,
15    pub reason_code: &'static str,
16    pub detail: String,
17}
18
19impl PathVerifyResult {
20    pub fn accept(reason: &'static str, detail: impl Into<String>) -> Self {
21        Self {
22            accepted: true,
23            reason_code: reason,
24            detail: detail.into(),
25        }
26    }
27    pub fn reject(reason: &'static str, detail: impl Into<String>) -> Self {
28        Self {
29            accepted: false,
30            reason_code: reason,
31            detail: detail.into(),
32        }
33    }
34}
35
36/// Split PEM into certificate DER blobs (CERTIFICATE blocks only).
37pub fn pem_to_ders(pem: &str) -> Result<Vec<Vec<u8>>, String> {
38    let mut out = Vec::new();
39    let mut rest = pem.as_bytes();
40    loop {
41        match rustls_pemfile::read_one_from_slice(rest) {
42            Ok(Some((item, rem))) => {
43                rest = rem;
44                match item {
45                    rustls_pemfile::Item::X509Certificate(der) => out.push(der.to_vec()),
46                    _ => {}
47                }
48            }
49            Ok(None) => break,
50            Err(e) => {
51                if out.is_empty() {
52                    return Err(format!("PEM parse: {e:?}"));
53                }
54                break;
55            }
56        }
57    }
58    if out.is_empty() {
59        return Err("no CERTIFICATE blocks in PEM".into());
60    }
61    Ok(out)
62}
63
64/// SHA-256 fingerprint of SubjectPublicKeyInfo (SPKI) as lowercase hex.
65pub fn spki_sha256_hex(cert_der: &[u8]) -> Result<String, String> {
66    let (_, cert) = X509Certificate::from_der(cert_der).map_err(|e| format!("x509 parse: {e}"))?;
67    let spki = cert.public_key().raw;
68    let mut h = Sha256::new();
69    h.update(spki);
70    Ok(hex::encode(h.finalize()))
71}
72
73/// SPKI pin match: leaf SPKI fingerprint equals expected hex (case-insensitive).
74pub fn spki_pin_matches(leaf_pem_or_der: &str, expected_hex: &str) -> Result<bool, String> {
75    let ders = if leaf_pem_or_der.contains("BEGIN CERTIFICATE") {
76        pem_to_ders(leaf_pem_or_der)?
77    } else {
78        // treat as hex DER? not supported — require PEM
79        return Err("leaf must be PEM CERTIFICATE".into());
80    };
81    let leaf = ders.first().ok_or("empty leaf")?;
82    let got = spki_sha256_hex(leaf)?;
83    Ok(got.eq_ignore_ascii_case(expected_hex.trim()))
84}
85
86/// Verify leaf (and optional intermediates) against **enabled** PEM roots in the store.
87///
88/// Strategy (bounded):
89/// 1. Parse leaf + intermediates + each enabled root.
90/// 2. Accept if leaf signature verifies against any enabled root's public key, or
91///    leaf verifies against an intermediate that verifies against a root.
92/// 3. Otherwise reject with reason codes.
93pub fn verify_chain_against_enabled_roots(
94    leaf_pem: &str,
95    intermediate_pems: &[&str],
96    store: &TrustStore,
97) -> PathVerifyResult {
98    let leaf_ders = match pem_to_ders(leaf_pem) {
99        Ok(d) => d,
100        Err(e) => return PathVerifyResult::reject("leaf_pem_invalid", e),
101    };
102    let leaf_der = match leaf_ders.first() {
103        Some(d) => d.as_slice(),
104        None => return PathVerifyResult::reject("leaf_empty", "no leaf certificate"),
105    };
106    let leaf = match X509Certificate::from_der(leaf_der) {
107        Ok((_, c)) => c,
108        Err(e) => return PathVerifyResult::reject("leaf_parse", format!("{e}")),
109    };
110
111    let mut intermediate_ders: Vec<Vec<u8>> = Vec::new();
112    for p in intermediate_pems {
113        if p.trim().is_empty() {
114            continue;
115        }
116        match pem_to_ders(p) {
117            Ok(ders) => intermediate_ders.extend(ders),
118            Err(e) => {
119                return PathVerifyResult::reject("intermediate_pem_invalid", e);
120            }
121        }
122    }
123
124    let mut roots: Vec<(String, Vec<u8>)> = Vec::new();
125    for a in store
126        .anchors
127        .iter()
128        .filter(|a| a.enabled && a.kind == crate::webizen_trust::AnchorKind::PemRoot)
129    {
130        if let Ok(ders) = pem_to_ders(&a.material) {
131            for d in ders {
132                if X509Certificate::from_der(&d).is_ok() {
133                    roots.push((a.id.clone(), d));
134                }
135            }
136        }
137    }
138    if roots.is_empty() {
139        return PathVerifyResult::reject(
140            "no_enabled_roots",
141            "no enabled PEM roots in trust store — chain verify fails closed",
142        );
143    }
144
145    // Direct: leaf signed by root
146    for (id, root_der) in &roots {
147        let Ok((_, root)) = X509Certificate::from_der(root_der) else {
148            continue;
149        };
150        if leaf.verify_signature(Some(root.public_key())).is_ok() {
151            return PathVerifyResult::accept(
152                "leaf_signed_by_enabled_root",
153                format!("verified against root {id}"),
154            );
155        }
156        if leaf.tbs_certificate.subject == root.tbs_certificate.subject
157            && leaf.tbs_certificate.subject == leaf.tbs_certificate.issuer
158            && leaf.verify_signature(None).is_ok()
159        {
160            return PathVerifyResult::accept(
161                "self_signed_matches_enabled_root",
162                format!("self-signed leaf matches enabled root {id}"),
163            );
164        }
165    }
166
167    // One hop: leaf → intermediate → root
168    for inter_der in &intermediate_ders {
169        let Ok((_, inter)) = X509Certificate::from_der(inter_der) else {
170            continue;
171        };
172        if leaf.verify_signature(Some(inter.public_key())).is_err() {
173            continue;
174        }
175        for (id_r, root_der) in &roots {
176            let Ok((_, root)) = X509Certificate::from_der(root_der) else {
177                continue;
178            };
179            if inter.verify_signature(Some(root.public_key())).is_ok() {
180                return PathVerifyResult::accept(
181                    "leaf_via_intermediate_to_enabled_root",
182                    format!("chain to root {id_r}"),
183                );
184            }
185        }
186    }
187
188    PathVerifyResult::reject(
189        "chain_not_anchored",
190        "leaf/intermediates did not verify to any enabled PEM root",
191    )
192}
193
194/// Build a rustls `RootCertStore` from enabled PEM roots (for agent TLS).
195pub fn root_cert_store_from_trust(store: &TrustStore) -> Result<rustls::RootCertStore, String> {
196    let mut roots = rustls::RootCertStore::empty();
197    let mut n = 0usize;
198    for a in store
199        .anchors
200        .iter()
201        .filter(|a| a.enabled && a.kind == crate::webizen_trust::AnchorKind::PemRoot)
202    {
203        let ders = pem_to_ders(&a.material)?;
204        for der in ders {
205            let cert = rustls::pki_types::CertificateDer::from(der);
206            roots
207                .add(cert)
208                .map_err(|e| format!("add root to RootCertStore: {e}"))?;
209            n += 1;
210        }
211    }
212    if n == 0 {
213        return Err("no enabled PEM roots to build RootCertStore".into());
214    }
215    Ok(roots)
216}
217
218/// Honesty: how agent TLS is configured.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum AgentTlsMode {
221    /// Custom roots only (fail closed if empty).
222    CustomRootsOnly { n_roots: usize },
223    /// System / default reqwest rustls roots (no custom PEMs enabled).
224    SystemDefault,
225}
226
227pub fn agent_tls_mode(store: &TrustStore) -> AgentTlsMode {
228    let n = store
229        .anchors
230        .iter()
231        .filter(|a| a.enabled && a.kind == crate::webizen_trust::AnchorKind::PemRoot)
232        .count();
233    if n > 0 {
234        AgentTlsMode::CustomRootsOnly { n_roots: n }
235    } else {
236        AgentTlsMode::SystemDefault
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::webizen_trust::{AnchorKind, TrustAnchor, TrustStore};
244
245    /// Minimal self-signed PEM is hard without rcgen; test reject paths + empty roots.
246    #[test]
247    fn empty_roots_fail_closed() {
248        let store = TrustStore::new();
249        let r = verify_chain_against_enabled_roots(
250            "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
251            &[],
252            &store,
253        );
254        assert!(!r.accepted);
255        // either parse fail or no_enabled_roots
256        assert!(
257            r.reason_code == "no_enabled_roots"
258                || r.reason_code == "leaf_parse"
259                || r.reason_code == "leaf_pem_invalid"
260        );
261    }
262
263    #[test]
264    fn agent_mode_system_when_no_pem() {
265        assert_eq!(
266            agent_tls_mode(&TrustStore::new()),
267            AgentTlsMode::SystemDefault
268        );
269    }
270
271    #[test]
272    fn agent_mode_custom_when_pem_enabled() {
273        let mut s = TrustStore::new();
274        s.anchors.push(TrustAnchor {
275            id: "pem:t".into(),
276            label: "t".into(),
277            kind: AnchorKind::PemRoot,
278            material: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n".into(),
279            enabled: true,
280            notes: "".into(),
281            added_unix: 1,
282        });
283        match agent_tls_mode(&s) {
284            AgentTlsMode::CustomRootsOnly { n_roots } => assert_eq!(n_roots, 1),
285            _ => panic!("expected custom"),
286        }
287    }
288}