Skip to main content

_synta/
x509_verification.rs

1//! Python bindings for X.509 certificate chain verification.
2//!
3//! This module builds the ``synta.x509`` Python submodule, exposing
4//! RFC 5280 / CABF certificate path validation powered by
5//! [`synta_x509_verification`] with the default signature backend
6//! from [`synta_certificate`].
7//!
8//! # Quick start
9//!
10//! ```python,ignore
11//! import synta
12//! import synta.x509 as x509
13//!
14//! # Load trust anchors (DER bytes).
15//! with open("root.der", "rb") as f:
16//!     root_der = f.read()
17//! store = x509.TrustStore([root_der])
18//!
19//! # Verify a TLS server certificate chain.
20//! with open("leaf.der", "rb") as f:
21//!     leaf_der = f.read()
22//! policy = x509.VerificationPolicy(server_names=["example.com"])
23//! chain = x509.verify_server_certificate(leaf_der, [], store, policy)
24//! # chain is list[bytes], root-first: chain[0] is the trust anchor.
25//! for cert_der in chain:
26//!     cert = synta.Certificate.from_der(cert_der)
27//!     print(cert.subject)
28//!
29//! # CRL-based revocation checking (optional):
30//! crl_ders = synta.pem_to_der(open("issuing-ca.crl", "rb").read())
31//! crl_store = x509.CrlStore(crl_ders)
32//! chain = x509.verify_server_certificate(leaf_der, [], store, policy, crls=crl_store)
33//!
34//! # OCSP-based revocation checking (optional):
35//! ocsp_resp_der = open("ocsp-response.der", "rb").read()
36//! ocsp_store = x509.OcspStore([ocsp_resp_der])
37//! chain = x509.verify_server_certificate(leaf_der, [], store, policy, ocsp=ocsp_store)
38//!
39//! # Both CRL and OCSP revocation at once:
40//! chain = x509.verify_server_certificate(
41//!     leaf_der, [], store, policy, crls=crl_store, ocsp=ocsp_store
42//! )
43//! ```
44
45use std::net::IpAddr;
46use std::time::{SystemTime, UNIX_EPOCH};
47
48use pyo3::exceptions::PyValueError;
49use pyo3::prelude::*;
50use pyo3::types::PyBytes;
51
52use synta::{Decoder, Encoding};
53use synta_certificate::{default_signature_verifier, Certificate, ErasedSignatureVerifier};
54use synta_x509_verification::{
55    ocsp::OcspStore,
56    ops::VerificationCertificate,
57    policy::{NameMatchMode, PolicyDefinition, Subject, ValidationProfile, VerificationPolicy},
58    revocation::CrlStore,
59    trust_store::OwnedStore,
60    types::{DNSName, IPAddress},
61    verify, RevocationChecks,
62};
63
64use crate::error::SyntaErr;
65use crate::install_submodule;
66
67// ── Python exception ─────────────────────────────────────────────────────────
68
69pyo3::create_exception!(
70    synta.x509,
71    X509VerificationError,
72    pyo3::exceptions::PyException
73);
74
75// ── Python types ─────────────────────────────────────────────────────────────
76
77/// A set of trusted CA certificates for X.509 chain verification.
78///
79/// Construct with a list of DER-encoded certificate bytes:
80///
81/// ```python,ignore
82/// store = TrustStore([root_ca_der, cross_root_der])
83/// ```
84///
85/// Pass PEM-encoded certificates through :func:`synta.pem_to_der` first:
86///
87/// ```python,ignore
88/// ders = synta.pem_to_der(open("roots.pem", "rb").read())
89/// store = TrustStore(ders)
90/// ```
91#[pyclass(frozen, name = "TrustStore")]
92pub struct PyTrustStore {
93    store: OwnedStore,
94}
95
96#[pymethods]
97impl PyTrustStore {
98    /// Create a trust store from a list of DER-encoded CA certificates.
99    ///
100    /// Each entry in ``certs_der`` must be the DER bytes of a single
101    /// certificate.  The certificates are parsed and indexed once at
102    /// construction time; subsequent verification calls incur no CA parsing
103    /// overhead.
104    ///
105    /// Raises :exc:`synta.SyntaError` if any entry is not a valid DER
106    /// certificate.
107    #[new]
108    fn new(certs_der: Vec<Vec<u8>>) -> PyResult<Self> {
109        let store =
110            OwnedStore::try_new(certs_der.iter().map(|v| v.as_slice())).map_err(SyntaErr)?;
111        Ok(PyTrustStore { store })
112    }
113
114    fn __repr__(&self) -> String {
115        format!("TrustStore(<{} certificate(s)>)", self.store.len())
116    }
117
118    /// Number of trusted certificates in this store.
119    #[getter]
120    fn len(&self) -> usize {
121        self.store.len()
122    }
123}
124
125/// A store of Certificate Revocation Lists (CRLs) for revocation checking.
126///
127/// Construct with a list of DER-encoded CRL byte strings:
128///
129/// ```python,ignore
130/// crl_store = CrlStore([crl_der])
131/// ```
132///
133/// Pass PEM-encoded CRLs through :func:`synta.pem_to_der` first:
134///
135/// ```python,ignore
136/// crl_ders = synta.pem_to_der(open("crl.pem", "rb").read())
137/// crl_store = CrlStore(crl_ders)
138/// ```
139///
140/// Pass a populated :class:`CrlStore` to :func:`verify_server_certificate` or
141/// :func:`verify_client_certificate` via the ``crls`` keyword argument to enable
142/// CRL-based revocation checking.  Certificates whose issuing CA has no matching
143/// CRL in the store are treated as not-revoked (soft-fail).
144#[pyclass(frozen, name = "CrlStore")]
145pub struct PyCrlStore {
146    crl_ders: Vec<Vec<u8>>,
147}
148
149#[pymethods]
150impl PyCrlStore {
151    /// Create a CRL store from a list of DER-encoded CRLs.
152    ///
153    /// Each entry in ``crl_ders`` must be the DER bytes of a single CRL.
154    #[new]
155    fn new(crl_ders: Vec<Vec<u8>>) -> Self {
156        PyCrlStore { crl_ders }
157    }
158
159    fn __repr__(&self) -> String {
160        format!("CrlStore(<{} CRL(s)>)", self.crl_ders.len())
161    }
162
163    /// Number of CRLs in this store.
164    #[getter]
165    fn len(&self) -> usize {
166        self.crl_ders.len()
167    }
168}
169
170/// A store of pre-fetched OCSP responses for revocation checking.
171///
172/// Construct with a list of DER-encoded OCSP response byte strings:
173///
174/// ```python,ignore
175/// ocsp_store = OcspStore([ocsp_resp_der])
176/// ```
177///
178/// Pass a populated :class:`OcspStore` to :func:`verify_server_certificate` or
179/// :func:`verify_client_certificate` via the ``ocsp`` keyword argument to enable
180/// OCSP-based revocation checking.  Certificates for which no valid, matching
181/// OCSP response is found are treated as not-revoked (soft-fail).
182#[pyclass(frozen, name = "OcspStore")]
183pub struct PyOcspStore {
184    ocsp_ders: Vec<Vec<u8>>,
185}
186
187#[pymethods]
188impl PyOcspStore {
189    /// Create an OCSP store from a list of DER-encoded OCSP responses.
190    ///
191    /// Each entry in ``ocsp_ders`` must be the DER bytes of a single OCSP
192    /// response (the outer ``OCSPResponse`` SEQUENCE as defined in RFC 6960).
193    #[new]
194    fn new(ocsp_ders: Vec<Vec<u8>>) -> Self {
195        PyOcspStore { ocsp_ders }
196    }
197
198    fn __repr__(&self) -> String {
199        format!("OcspStore(<{} OCSP response(s)>)", self.ocsp_ders.len())
200    }
201
202    /// Number of OCSP responses in this store.
203    #[getter]
204    fn len(&self) -> usize {
205        self.ocsp_ders.len()
206    }
207}
208
209/// Optional parameters that control certificate chain verification.
210///
211/// All fields have safe defaults so you only need to set what differs from
212/// the standard WebPKI TLS server / client validation.
213///
214/// ``server_names`` accepts a list of DNS hostnames or IP address literals.
215/// When more than one name is given, ``name_match`` controls whether the
216/// certificate must cover **any** (default) or **all** of them:
217///
218/// * ``"any"`` — connection validation: the certificate is accepted if it
219///   matches at least one name in the list (e.g. you are connecting to one
220///   of several possible endpoints).
221/// * ``"all"`` — cert assessment: the certificate must cover every name
222///   (e.g. checking a cert covers your entire domain set before deploying).
223///
224/// ```python,ignore
225/// import synta.x509 as x509
226///
227/// # Single name — classic behaviour:
228/// policy = x509.VerificationPolicy(server_names=["example.com"])
229///
230/// # Any-match: accept the cert if it covers either name (connection validation):
231/// policy = x509.VerificationPolicy(
232///     server_names=["example.com", "www.example.com"],
233///     name_match="any",
234/// )
235///
236/// # All-match: verify the cert covers every name (cert assessment):
237/// policy = x509.VerificationPolicy(
238///     server_names=["example.com", "api.example.com"],
239///     name_match="all",
240/// )
241///
242/// # Strict RFC 5280 profile with a fixed validation time, no SAN check:
243/// policy = x509.VerificationPolicy(
244///     profile="rfc5280",
245///     validation_time=1_700_000_000,
246///     max_chain_depth=4,
247/// )
248/// ```
249#[pyclass(name = "VerificationPolicy")]
250pub struct PyVerificationPolicy {
251    inner: VerificationPolicy,
252}
253
254#[pymethods]
255impl PyVerificationPolicy {
256    #[new]
257    #[pyo3(
258        signature = (*, server_names=None, name_match=None, validation_time=None, max_chain_depth=8, profile=None)
259    )]
260    fn new(
261        server_names: Option<Vec<String>>,
262        name_match: Option<String>,
263        validation_time: Option<i64>,
264        max_chain_depth: u8,
265        profile: Option<String>,
266    ) -> PyResult<Self> {
267        let vprofile = match profile.as_deref() {
268            None | Some("webpki") => ValidationProfile::WebPki,
269            Some("rfc5280") => ValidationProfile::Rfc5280,
270            Some(other) => {
271                return Err(PyValueError::new_err(format!(
272                    "unknown validation profile {other:?}; expected \"webpki\" or \"rfc5280\""
273                )));
274            }
275        };
276        let vmatch = match name_match.as_deref() {
277            None | Some("any") => NameMatchMode::Any,
278            Some("all") => NameMatchMode::All,
279            Some(other) => {
280                return Err(PyValueError::new_err(format!(
281                    "unknown name_match {other:?}; expected \"any\" or \"all\""
282                )));
283            }
284        };
285        Ok(PyVerificationPolicy {
286            inner: VerificationPolicy {
287                server_names: server_names.unwrap_or_default(),
288                name_match: vmatch,
289                validation_time,
290                max_chain_depth,
291                profile: vprofile,
292            },
293        })
294    }
295
296    fn __repr__(&self) -> String {
297        let profile = match self.inner.profile {
298            ValidationProfile::WebPki => "webpki",
299            ValidationProfile::Rfc5280 => "rfc5280",
300        };
301        let name_match = match self.inner.name_match {
302            NameMatchMode::Any => "any",
303            NameMatchMode::All => "all",
304        };
305        format!(
306            "VerificationPolicy(server_names={:?}, name_match={:?}, profile={:?}, \
307             max_chain_depth={}, validation_time={:?})",
308            self.inner.server_names,
309            name_match,
310            profile,
311            self.inner.max_chain_depth,
312            self.inner.validation_time,
313        )
314    }
315}
316
317// ── Helpers ───────────────────────────────────────────────────────────────────
318
319fn now_unix() -> i64 {
320    SystemTime::now()
321        .duration_since(UNIX_EPOCH)
322        .map(|d| d.as_secs() as i64)
323        .unwrap_or(0)
324}
325
326/// Parse a single DER cert.
327fn parse_vcert(der: &[u8]) -> PyResult<Certificate<'_>> {
328    Ok(Decoder::new(der, Encoding::Der)
329        .decode::<Certificate>()
330        .map_err(SyntaErr)?)
331}
332
333/// Core verifier: build `VerificationCertificate`s from borrowed DER slices,
334/// run `verify()` with a [`RevocationChecks`] struct, and return the chain as
335/// owned DER byte vectors.
336///
337/// `intermediate_ders` holds owned byte vecs (kept alive by the caller) so
338/// we can take stable `&[u8]` references into them.
339fn run_verify<'a>(
340    leaf_der: &'a [u8],
341    intermediate_ders: &'a [Vec<u8>],
342    trust_store: &'a PyTrustStore,
343    policy: PolicyDefinition<'a, Box<dyn ErasedSignatureVerifier>>,
344    crls: Option<&'a CrlStore>,
345    ocsp: Option<&'a OcspStore>,
346) -> PyResult<Vec<Vec<u8>>> {
347    let leaf_cert = parse_vcert(leaf_der)?;
348    let leaf_vcert = VerificationCertificate::new(leaf_cert, leaf_der);
349
350    let mut intermediate_vcerts = Vec::with_capacity(intermediate_ders.len());
351    for der in intermediate_ders {
352        let cert = parse_vcert(der.as_slice())?;
353        intermediate_vcerts.push(VerificationCertificate::new(cert, der.as_slice()));
354    }
355
356    // Trust anchors are pre-parsed in OwnedStore — no per-call CA parsing.
357    let store = trust_store.store.as_store();
358
359    verify(
360        &leaf_vcert,
361        &intermediate_vcerts,
362        &policy,
363        store,
364        RevocationChecks { crls, ocsp },
365    )
366    .map(|chain| chain.into_iter().map(|vc| vc.der().to_vec()).collect())
367    .map_err(|e| X509VerificationError::new_err(e.to_string()))
368}
369
370// ── Python functions ──────────────────────────────────────────────────────────
371
372/// Parse a name string (DNS hostname or IP literal) into a [`Subject`].
373fn parse_subject(name: &str) -> PyResult<Subject<'_>> {
374    if let Ok(ip) = name.parse::<IpAddr>() {
375        let addr = match ip {
376            IpAddr::V4(a) => IPAddress::from_bytes(&a.octets()),
377            IpAddr::V6(a) => IPAddress::from_bytes(&a.octets()),
378        };
379        Ok(Subject::Ip(addr.ok_or_else(|| {
380            PyValueError::new_err(format!("invalid IP address for server name: {name}"))
381        })?))
382    } else {
383        Ok(Subject::Dns(DNSName::new(name).ok_or_else(|| {
384            PyValueError::new_err(format!("invalid DNS name for server name: {name:?}"))
385        })?))
386    }
387}
388
389/// Verify a TLS server certificate chain using the WebPKI / CABF profile.
390///
391/// ``leaf_der`` is the DER-encoded end-entity certificate.
392/// ``intermediates_der`` is a list of DER-encoded intermediate CA certificates
393/// (order does not matter; the validator discovers the chain automatically).
394/// ``trust_store`` is a :class:`TrustStore` populated with trusted root CAs.
395/// ``policy`` is an optional :class:`VerificationPolicy` specifying the server
396/// names, name match mode, validation time, chain depth, and compliance profile.
397///
398/// Returns the validated chain as ``list[bytes]``, ordered root-first
399/// (``chain[0]`` is the trust anchor, ``chain[-1]`` is the leaf).
400///
401/// Raises :exc:`X509VerificationError` if verification fails.
402///
403/// ```python,ignore
404/// import synta
405/// import synta.x509 as x509
406///
407/// store = x509.TrustStore([root_der])
408///
409/// # Single name:
410/// policy = x509.VerificationPolicy(server_names=["example.com"])
411/// chain = x509.verify_server_certificate(leaf_der, [intermediate_der], store, policy)
412/// for i, cert_der in enumerate(chain):
413///     cert = synta.Certificate.from_der(cert_der)
414///     print(f"chain[{i}]: {cert.subject}")
415///
416/// # Multi-name, all-match (cert covers every name):
417/// policy = x509.VerificationPolicy(
418///     server_names=["example.com", "api.example.com"],
419///     name_match="all",
420/// )
421/// chain = x509.verify_server_certificate(leaf_der, [], store, policy)
422/// ```
423#[pyfunction]
424#[pyo3(signature = (leaf_der, intermediates_der, trust_store, policy=None, crls=None, ocsp=None))]
425fn verify_server_certificate<'py>(
426    py: Python<'py>,
427    leaf_der: Vec<u8>,
428    intermediates_der: Vec<Vec<u8>>,
429    trust_store: &PyTrustStore,
430    policy: Option<&PyVerificationPolicy>,
431    crls: Option<&PyCrlStore>,
432    ocsp: Option<&PyOcspStore>,
433) -> PyResult<Vec<Bound<'py, PyBytes>>> {
434    let default_policy;
435    let vp: &VerificationPolicy = match policy {
436        Some(p) => &p.inner,
437        None => {
438            default_policy = VerificationPolicy::new_client();
439            &default_policy
440        }
441    };
442
443    let now = vp.validation_time.unwrap_or_else(now_unix);
444
445    let subjects = vp
446        .server_names
447        .iter()
448        .map(|name| parse_subject(name))
449        .collect::<PyResult<Vec<_>>>()?;
450
451    let mut pd = PolicyDefinition::new_server(default_signature_verifier(), subjects, now);
452    pd.profile = vp.profile;
453    pd.max_chain_depth = vp.max_chain_depth;
454    pd.name_match = vp.name_match;
455
456    // Build a CrlStore from the Python CRL store if provided.
457    let mut crl_store = CrlStore::new();
458    if let Some(py_crls) = crls {
459        for der in &py_crls.crl_ders {
460            crl_store.add_der(der.clone());
461        }
462    }
463    let crl_opt = crls.map(|_| &crl_store);
464
465    // Build an OcspStore from the Python OCSP store if provided.
466    let mut ocsp_store = OcspStore::new();
467    if let Some(py_ocsp) = ocsp {
468        for der in &py_ocsp.ocsp_ders {
469            ocsp_store.add_der(der.clone());
470        }
471    }
472    let ocsp_opt = ocsp.map(|_| &ocsp_store);
473
474    run_verify(
475        leaf_der.as_slice(),
476        &intermediates_der,
477        trust_store,
478        pd,
479        crl_opt,
480        ocsp_opt,
481    )?
482    .into_iter()
483    .map(|der| Ok(PyBytes::new(py, &der)))
484    .collect()
485}
486
487/// Verify a TLS client certificate chain.
488///
489/// Like :func:`verify_server_certificate` but uses the ``clientAuth`` EKU and
490/// skips SAN / server-name matching.
491///
492/// ``policy`` may set ``validation_time``, ``max_chain_depth``, and
493/// ``profile``; its ``server_names`` field is ignored.
494///
495/// Returns the validated chain as ``list[bytes]``, root-first.
496///
497/// Raises :exc:`X509VerificationError` if verification fails.
498///
499/// ```python,ignore
500/// import synta.x509 as x509
501///
502/// store = x509.TrustStore([root_der])
503/// chain = x509.verify_client_certificate(leaf_der, [], store)
504/// ```
505#[pyfunction]
506#[pyo3(signature = (leaf_der, intermediates_der, trust_store, policy=None, crls=None, ocsp=None))]
507fn verify_client_certificate<'py>(
508    py: Python<'py>,
509    leaf_der: Vec<u8>,
510    intermediates_der: Vec<Vec<u8>>,
511    trust_store: &PyTrustStore,
512    policy: Option<&PyVerificationPolicy>,
513    crls: Option<&PyCrlStore>,
514    ocsp: Option<&PyOcspStore>,
515) -> PyResult<Vec<Bound<'py, PyBytes>>> {
516    let default_policy;
517    let vp: &VerificationPolicy = match policy {
518        Some(p) => &p.inner,
519        None => {
520            default_policy = VerificationPolicy::new_client();
521            &default_policy
522        }
523    };
524
525    let now = vp.validation_time.unwrap_or_else(now_unix);
526
527    let mut pd = PolicyDefinition::new_client(default_signature_verifier(), now);
528    pd.profile = vp.profile;
529    pd.max_chain_depth = vp.max_chain_depth;
530
531    // Build a CrlStore from the Python CRL store if provided.
532    let mut crl_store = CrlStore::new();
533    if let Some(py_crls) = crls {
534        for der in &py_crls.crl_ders {
535            crl_store.add_der(der.clone());
536        }
537    }
538    let crl_opt = crls.map(|_| &crl_store);
539
540    // Build an OcspStore from the Python OCSP store if provided.
541    let mut ocsp_store = OcspStore::new();
542    if let Some(py_ocsp) = ocsp {
543        for der in &py_ocsp.ocsp_ders {
544            ocsp_store.add_der(der.clone());
545        }
546    }
547    let ocsp_opt = ocsp.map(|_| &ocsp_store);
548
549    run_verify(
550        leaf_der.as_slice(),
551        &intermediates_der,
552        trust_store,
553        pd,
554        crl_opt,
555        ocsp_opt,
556    )?
557    .into_iter()
558    .map(|der| Ok(PyBytes::new(py, &der)))
559    .collect()
560}
561
562// ── Module registration ───────────────────────────────────────────────────────
563
564/// Register the ``synta.x509`` submodule onto ``parent``.
565pub fn register_x509_module(parent: &Bound<'_, PyModule>) -> PyResult<()> {
566    let py = parent.py();
567    let m = PyModule::new(py, "x509")?;
568
569    m.add(
570        "X509VerificationError",
571        py.get_type::<X509VerificationError>(),
572    )?;
573    m.add_class::<PyTrustStore>()?;
574    m.add_class::<PyCrlStore>()?;
575    m.add_class::<PyOcspStore>()?;
576    m.add_class::<PyVerificationPolicy>()?;
577    m.add_function(wrap_pyfunction!(verify_server_certificate, &m)?)?;
578    m.add_function(wrap_pyfunction!(verify_client_certificate, &m)?)?;
579
580    install_submodule(
581        parent,
582        &m,
583        "synta.x509",
584        Some("RFC 5280 X.509 certificate chain verification."),
585    )?;
586    Ok(())
587}