Skip to main content

_synta/certificate/
cert_builder.rs

1//! Python bindings for building X.509 certificates natively.
2//!
3//! Delegates all DER construction and signing to
4//! [`synta_certificate::builder::CertificateBuilder`].  OpenSSL is only used
5//! for the signing step; all ASN.1 encoding is done inside the Rust builder.
6//!
7//! # Chaining
8//!
9//! Each setter returns the same `CertificateBuilder` object, enabling a
10//! single-expression build:
11//!
12//! ```python,ignore
13//! import synta, datetime
14//!
15//! cert = (
16//!     synta.CertificateBuilder()
17//!     .issuer_name(ca_cert.subject_raw_der)      # zero re-encode
18//!     .subject_name(csr.subject_raw_der)         # zero re-encode
19//!     .public_key_der(csr.subject_public_key_info_der)  # zero re-encode
20//!     .serial_number(42)
21//!     .not_valid_before_utc(datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc))
22//!     .not_valid_after_utc(datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc))
23//!     .add_extension("2.5.29.19", True, basic_constraints_der)
24//!     .sign(ca_key, "sha256")
25//! )
26//! ```
27
28use pyo3::exceptions::PyValueError;
29use pyo3::prelude::*;
30use pyo3::types::PyBytes;
31use pyo3::Py;
32use synta::{Integer, ObjectIdentifier};
33use synta_certificate::{
34    CertificateBuilder, CertificateSigner as _, PrivateKey as _, Time, UnsignedCertificateSigner,
35};
36
37use crate::certificate::cert::PyCertificate;
38use crate::crypto_keys::PyPrivateKey;
39
40// ── Helpers ───────────────────────────────────────────────────────────────────
41
42/// Convert a Python `datetime.datetime` (or any object with year/month/day/
43/// hour/minute/second attributes) to a synta `Time`.
44///
45/// Uses `UtcTime` for years 1950–2049 (RFC 5280 §4.1.2.5), `GeneralizedTime`
46/// otherwise.
47fn py_to_synta_time(dt: &Bound<'_, PyAny>) -> PyResult<Time> {
48    // Reject naive datetimes (tzinfo is None) to prevent silent UTC misinterpretation.
49    if dt.getattr("tzinfo")?.is_none() {
50        return Err(PyValueError::new_err(
51            "datetime must be timezone-aware (tzinfo must not be None); \
52             use datetime.timezone.utc to specify UTC",
53        ));
54    }
55    let year = dt.getattr("year")?.extract::<u32>()?;
56    let month = dt.getattr("month")?.extract::<u8>()?;
57    let day = dt.getattr("day")?.extract::<u8>()?;
58    let hour = dt.getattr("hour")?.extract::<u8>()?;
59    let minute = dt.getattr("minute")?.extract::<u8>()?;
60    let second = dt.getattr("second")?.extract::<u8>()?;
61    if (1950..=2049).contains(&year) {
62        Ok(Time::UtcTime(
63            synta::UtcTime::new(year as u16, month, day, hour, minute, second)
64                .map_err(|e| PyValueError::new_err(format!("invalid UTCTime: {e}")))?,
65        ))
66    } else {
67        Ok(Time::GeneralTime(
68            synta::GeneralizedTime::new(year as u16, month, day, hour, minute, second, None)
69                .map_err(|e| PyValueError::new_err(format!("invalid GeneralizedTime: {e}")))?,
70        ))
71    }
72}
73
74// ── CertificateBuilder ────────────────────────────────────────────────────────
75
76/// Builder for X.509 v3 certificates.
77///
78/// All Name, SubjectPublicKeyInfo, and extension-value bytes are stored as-is
79/// and spliced verbatim into the TBS DER at signing time — no re-parse or
80/// re-encode.  The only allocation per field is the initial copy of the byte
81/// slice into the builder's owned storage.
82///
83/// Each setter method returns the **same** builder object, so calls can be
84/// chained with ``.``:
85///
86/// ```python,ignore
87/// import synta, datetime
88///
89/// now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
90/// then = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
91///
92/// cert = (
93///     synta.CertificateBuilder()
94///     .issuer_name(ca_cert.subject_raw_der)
95///     .subject_name(csr.subject_raw_der)
96///     .public_key_der(csr.subject_public_key_info_der)
97///     .serial_number(42)
98///     .not_valid_before_utc(now)
99///     .not_valid_after_utc(then)
100///     .add_extension("2.5.29.19", True, bc_der)
101///     .sign(ca_key, "sha256")
102/// )
103/// ```
104#[pyclass(name = "CertificateBuilder")]
105pub struct PyCertificateBuilder {
106    /// Issuer Name TLV (pre-encoded DER).
107    issuer: Option<Vec<u8>>,
108    /// Subject Name TLV (pre-encoded DER).
109    subject: Option<Vec<u8>>,
110    /// SubjectPublicKeyInfo TLV (pre-encoded DER).
111    spki: Option<Vec<u8>>,
112    /// Certificate serial number.
113    serial: Option<Integer>,
114    /// notBefore validity time.
115    not_before: Option<Time>,
116    /// notAfter validity time.
117    not_after: Option<Time>,
118    /// Extensions: (OID, critical, extension-value DER bytes).
119    extensions: Vec<(ObjectIdentifier, bool, Vec<u8>)>,
120}
121
122#[pymethods]
123impl PyCertificateBuilder {
124    /// Create a new, empty ``CertificateBuilder``.
125    #[new]
126    fn new() -> Self {
127        Self {
128            issuer: None,
129            subject: None,
130            spki: None,
131            serial: None,
132            not_before: None,
133            not_after: None,
134            extensions: Vec::new(),
135        }
136    }
137
138    /// Set the issuer Name from pre-encoded DER bytes.
139    ///
140    /// Accepts any bytes object that is a valid DER-encoded ``Name``
141    /// SEQUENCE TLV.  The bytes from ``Certificate.subject_raw_der`` or
142    /// ``Certificate.issuer_raw_der`` are suitable directly:
143    ///
144    /// ```python,ignore
145    /// builder.issuer_name(ca_cert.subject_raw_der)
146    /// ```
147    fn issuer_name<'py>(slf: Bound<'py, Self>, name_der: &[u8]) -> Bound<'py, Self> {
148        slf.borrow_mut().issuer = Some(name_der.to_vec());
149        slf
150    }
151
152    /// Set the subject Name from pre-encoded DER bytes.
153    ///
154    /// ```python,ignore
155    /// builder.subject_name(csr.subject_raw_der)
156    /// ```
157    fn subject_name<'py>(slf: Bound<'py, Self>, name_der: &[u8]) -> Bound<'py, Self> {
158        slf.borrow_mut().subject = Some(name_der.to_vec());
159        slf
160    }
161
162    /// Set the SubjectPublicKeyInfo from a :class:`PublicKey` object.
163    ///
164    /// Serializes the key to SPKI DER once and stores the bytes.
165    ///
166    /// ```python,ignore
167    /// builder.public_key(csr_pub_key)
168    /// ```
169    fn public_key<'py>(
170        slf: Bound<'py, Self>,
171        key: &crate::crypto_keys::PyPublicKey,
172    ) -> PyResult<Bound<'py, Self>> {
173        let der = key
174            .inner
175            .to_der()
176            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
177        slf.borrow_mut().spki = Some(der);
178        Ok(slf)
179    }
180
181    /// Set the SubjectPublicKeyInfo from pre-encoded SPKI DER bytes.
182    ///
183    /// Stores the bytes verbatim — no re-parsing.  Suitable for passing
184    /// ``Certificate.subject_public_key_info_der`` or
185    /// ``csr.subject_public_key_info_der`` directly:
186    ///
187    /// ```python,ignore
188    /// builder.public_key_der(csr.subject_public_key_info_der)
189    /// ```
190    fn public_key_der<'py>(slf: Bound<'py, Self>, spki_der: &[u8]) -> Bound<'py, Self> {
191        slf.borrow_mut().spki = Some(spki_der.to_vec());
192        slf
193    }
194
195    /// Set the certificate serial number.
196    ///
197    /// Accepts a Python ``int`` (any size) or ``bytes``
198    /// (big-endian two's-complement encoding).
199    ///
200    /// ```python,ignore
201    /// import os
202    /// builder.serial_number(42)
203    /// builder.serial_number(int.from_bytes(os.urandom(20), "big"))
204    /// ```
205    fn serial_number<'py>(
206        slf: Bound<'py, Self>,
207        n: &Bound<'py, PyAny>,
208    ) -> PyResult<Bound<'py, Self>> {
209        let serial = if let Ok(i) = n.extract::<i64>() {
210            Integer::from_i64(i)
211        } else if let Ok(b) = n.cast::<PyBytes>() {
212            Integer::from_bytes(b.as_bytes())
213        } else if n.is_instance_of::<pyo3::types::PyInt>() {
214            // Large Python int that doesn't fit in i64 — convert via to_bytes().
215            let bit_length: usize = n.call_method0("bit_length")?.extract()?;
216            let byte_length = bit_length.div_ceil(8).max(1);
217            let bytes_obj = n.call_method1("to_bytes", (byte_length, "big"))?;
218            let b = bytes_obj.cast::<PyBytes>()?;
219            Integer::from_bytes(b.as_bytes())
220        } else {
221            return Err(PyValueError::new_err("serial_number expects int or bytes"));
222        };
223        slf.borrow_mut().serial = Some(serial);
224        Ok(slf)
225    }
226
227    /// Set the ``notBefore`` validity time.
228    ///
229    /// Accepts a timezone-aware ``datetime.datetime`` object (or any object
230    /// with ``year``, ``month``, ``day``, ``hour``, ``minute``, ``second``,
231    /// and ``tzinfo`` attributes).  A naive datetime (``tzinfo=None``) raises
232    /// :exc:`ValueError`.  The time fields are treated as UTC.
233    ///
234    /// ```python,ignore
235    /// import datetime
236    /// builder.not_valid_before_utc(
237    ///     datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
238    /// )
239    /// ```
240    fn not_valid_before_utc<'py>(
241        slf: Bound<'py, Self>,
242        dt: &Bound<'py, PyAny>,
243    ) -> PyResult<Bound<'py, Self>> {
244        slf.borrow_mut().not_before = Some(py_to_synta_time(dt)?);
245        Ok(slf)
246    }
247
248    /// Set the ``notAfter`` validity time.
249    ///
250    /// ```python,ignore
251    /// import datetime
252    /// builder.not_valid_after_utc(
253    ///     datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
254    /// )
255    /// ```
256    fn not_valid_after_utc<'py>(
257        slf: Bound<'py, Self>,
258        dt: &Bound<'py, PyAny>,
259    ) -> PyResult<Bound<'py, Self>> {
260        slf.borrow_mut().not_after = Some(py_to_synta_time(dt)?);
261        Ok(slf)
262    }
263
264    /// Add an X.509 v3 extension.
265    ///
266    /// ``oid`` is the extension OID in dotted-decimal notation.
267    /// ``critical`` is ``True`` if the extension is critical.
268    /// ``value_der`` is the DER-encoded extension value — the raw bytes that
269    /// ``Certificate.get_extension_value_der(oid)`` returns (i.e., the
270    /// content of the OCTET STRING, *not* the OCTET STRING TLV itself).
271    ///
272    /// ```python,ignore
273    /// # Copy an extension from an existing cert:
274    /// bc_der = ca_cert.get_extension_value_der("2.5.29.19")
275    /// builder.add_extension("2.5.29.19", True, bc_der)
276    /// ```
277    fn add_extension<'py>(
278        slf: Bound<'py, Self>,
279        oid: &str,
280        critical: bool,
281        value_der: &[u8],
282    ) -> PyResult<Bound<'py, Self>> {
283        use std::str::FromStr;
284        let oid = ObjectIdentifier::from_str(oid)
285            .map_err(|_| PyValueError::new_err(format!("invalid OID: {oid}")))?;
286        slf.borrow_mut()
287            .extensions
288            .push((oid, critical, value_der.to_vec()));
289        Ok(slf)
290    }
291
292    /// Sign the certificate and return a :class:`Certificate`.
293    ///
294    /// ``key`` is the issuer's :class:`PrivateKey`.  ``algorithm`` is the
295    /// hash algorithm name — one of ``"sha1"``, ``"sha256"``, ``"sha384"``,
296    /// ``"sha512"`` for RSA and ECDSA keys.  For Ed25519 / Ed448 keys the
297    /// argument is ignored (no pre-hash is used).
298    ///
299    /// ``context`` is the ML-DSA context string (FIPS 204 domain separator).
300    /// It defaults to ``None`` (equivalent to an empty context).  Ignored for
301    /// non-ML-DSA keys.  When ``context`` is a non-empty ``bytes`` object and
302    /// the key is an ML-DSA key, the manual signing path is used:
303    /// ``build_tbs`` → ``sign_ml_dsa_with_context`` → ``assemble``.
304    ///
305    /// All required fields (issuer name, subject name, public key, serial
306    /// number, notBefore, notAfter) must have been set; a :exc:`ValueError`
307    /// is raised if any is missing.
308    ///
309    /// Raises :exc:`ValueError` on encoding or signing errors.
310    ///
311    /// ```python,ignore
312    /// cert = builder.sign(ca_key, "sha256")
313    /// ml_cert = builder.sign(ml_dsa_key, "sha256", context=b"my-app")
314    /// ```
315    #[pyo3(signature = (key, algorithm, context = None))]
316    fn sign<'py>(
317        &self,
318        py: Python<'py>,
319        key: &PyPrivateKey,
320        algorithm: &str,
321        context: Option<&[u8]>,
322    ) -> PyResult<Bound<'py, PyCertificate>> {
323        // Build the Rust-level builder from our stored fields.
324        let mut builder = CertificateBuilder::new();
325        if let Some(ref b) = self.issuer {
326            builder = builder.issuer_name(b);
327        }
328        if let Some(ref b) = self.subject {
329            builder = builder.subject_name(b);
330        }
331        if let Some(ref b) = self.spki {
332            builder = builder.public_key_der(b);
333        }
334        if let Some(ref s) = self.serial {
335            builder = builder.serial_number(s.clone());
336        }
337        if let Some(ref t) = self.not_before {
338            builder = builder.not_valid_before(t.clone());
339        }
340        if let Some(ref t) = self.not_after {
341            builder = builder.not_valid_after(t.clone());
342        }
343        for (oid, critical, value_bytes) in &self.extensions {
344            builder = builder.add_extension(oid.clone(), *critical, value_bytes);
345        }
346
347        // If a non-empty context is provided and this is an ML-DSA key, take the
348        // manual path: build_tbs → sign_ml_dsa_with_context → assemble.
349        let ctx = context.unwrap_or(b"");
350        let is_ml_dsa = matches!(
351            key.inner.key_type(),
352            "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87"
353        );
354        let cert_der = if !ctx.is_empty() && is_ml_dsa {
355            let signer = key.as_signer(algorithm);
356            let sig_alg_der = signer
357                .signature_algorithm_der()
358                .map_err(|e| PyValueError::new_err(format!("{e}")))?;
359            let tbs_der = builder
360                .build_tbs(&sig_alg_der)
361                .map_err(|e| PyValueError::new_err(format!("{e}")))?;
362            let signature = key
363                .inner
364                .sign_ml_dsa_with_context(&tbs_der, ctx)
365                .map_err(|e| PyValueError::new_err(format!("{e}")))?;
366            CertificateBuilder::assemble(&tbs_der, &sig_alg_der, &signature)
367                .map_err(|e| PyValueError::new_err(format!("{e}")))?
368        } else {
369            // Standard path: delegate to the signer trait object.
370            let signer = key.as_signer(algorithm);
371            builder
372                .sign(&signer)
373                .map_err(|e| PyValueError::new_err(format!("{e}")))?
374        };
375
376        // Parse the assembled DER back into a PyCertificate.
377        let py_bytes = PyBytes::new(py, &cert_der);
378        let cert = PyCertificate::new_from_der(py, py_bytes)?;
379        Py::new(py, cert).map(|py_cert| py_cert.into_bound(py))
380    }
381
382    /// Sign the certificate using the RFC 9925 unsigned algorithm and return a
383    /// :class:`Certificate`.
384    ///
385    /// No private key is required.  The resulting certificate carries
386    /// ``id-alg-unsigned`` (1.3.6.1.5.5.7.6.36) in both
387    /// ``TBSCertificate.signature`` and the outer ``signatureAlgorithm``, with
388    /// a zero-length BIT STRING (``03 01 00``) as the ``signatureValue``.
389    ///
390    /// All required fields (issuer name, subject name, public key, serial
391    /// number, notBefore, notAfter) must have been set; a :exc:`ValueError`
392    /// is raised if any is missing.
393    ///
394    /// ```python,ignore
395    /// # Build an unsigned root CA certificate:
396    /// unsigned_root = (
397    ///     synta.CertificateBuilder()
398    ///     .issuer_name(issuer_name_der)
399    ///     .subject_name(subject_name_der)
400    ///     .public_key(root_key.public_key)
401    ///     .serial_number(1)
402    ///     .not_valid_before_utc(now)
403    ///     .not_valid_after_utc(expires)
404    ///     .add_extension("2.5.29.19", True, bc_der)
405    ///     .sign_unsigned()
406    /// )
407    /// ```
408    fn sign_unsigned<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyCertificate>> {
409        let mut builder = CertificateBuilder::new();
410        if let Some(ref b) = self.issuer {
411            builder = builder.issuer_name(b);
412        }
413        if let Some(ref b) = self.subject {
414            builder = builder.subject_name(b);
415        }
416        if let Some(ref b) = self.spki {
417            builder = builder.public_key_der(b);
418        }
419        if let Some(ref s) = self.serial {
420            builder = builder.serial_number(s.clone());
421        }
422        if let Some(ref t) = self.not_before {
423            builder = builder.not_valid_before(t.clone());
424        }
425        if let Some(ref t) = self.not_after {
426            builder = builder.not_valid_after(t.clone());
427        }
428        for (oid, critical, value_bytes) in &self.extensions {
429            builder = builder.add_extension(oid.clone(), *critical, value_bytes);
430        }
431
432        let cert_der = builder
433            .sign(&UnsignedCertificateSigner)
434            .map_err(|e| PyValueError::new_err(format!("{e}")))?;
435
436        let py_bytes = PyBytes::new(py, &cert_der);
437        let cert = PyCertificate::new_from_der(py, py_bytes)?;
438        Py::new(py, cert).map(|py_cert| py_cert.into_bound(py))
439    }
440
441    fn __repr__(&self) -> String {
442        let subject = self
443            .subject
444            .as_ref()
445            .map(|b| format!("{} bytes", b.len()))
446            .unwrap_or_else(|| "not set".to_string());
447        format!("CertificateBuilder(subject={subject})")
448    }
449}