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