_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}