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