_synta/certificate/csr_builder.rs
1//! Python bindings for building PKCS #10 Certificate Signing Requests.
2//!
3//! Delegates all DER construction and signing to
4//! [`synta_certificate::csr_builder::CsrBuilder`]. OpenSSL is only used for
5//! the signing step; all ASN.1 encoding is done inside the Rust builder.
6//!
7//! # Chaining
8//!
9//! Each setter returns the same `CsrBuilder` object, enabling a
10//! single-expression build:
11//!
12//! ```python,ignore
13//! import synta
14//!
15//! csr = (
16//! synta.CsrBuilder()
17//! .subject_name(name_der)
18//! .public_key(key.public_key())
19//! .add_extension("2.5.29.17", False, san_der)
20//! .sign(key, "sha256")
21//! )
22//! ```
23
24use pyo3::exceptions::PyValueError;
25use pyo3::prelude::*;
26use pyo3::types::PyBytes;
27use pyo3::Py;
28use synta::ObjectIdentifier;
29use synta_certificate::{CertificateSigner as _, CsrBuilder, PrivateKey as _};
30
31use crate::certificate::pkix::PyCsr;
32use crate::crypto_keys::PyPrivateKey;
33
34// ── CsrBuilder ────────────────────────────────────────────────────────────────
35
36/// Builder for PKCS #10 Certificate Signing Requests.
37///
38/// The subject Name and SubjectPublicKeyInfo bytes are stored as-is and spliced
39/// verbatim into the ``CertificationRequestInfo`` DER at signing time — no
40/// re-parse or re-encode.
41///
42/// Each setter method returns the **same** builder object, so calls can be
43/// chained with ``.``:
44///
45/// ```python,ignore
46/// import synta
47///
48/// csr = (
49/// synta.CsrBuilder()
50/// .subject_name(cert.subject_raw_der)
51/// .public_key(key.public_key())
52/// .add_extension("2.5.29.17", False, san_der)
53/// .sign(key, "sha256")
54/// )
55/// ```
56#[pyclass(name = "CsrBuilder")]
57pub struct PyCsrBuilder {
58 /// Subject Name TLV (pre-encoded DER).
59 subject: Option<Vec<u8>>,
60 /// SubjectPublicKeyInfo TLV (pre-encoded DER).
61 spki: Option<Vec<u8>>,
62 /// Extensions: (OID, critical, extension-value DER bytes).
63 extensions: Vec<(ObjectIdentifier, bool, Vec<u8>)>,
64}
65
66#[pymethods]
67impl PyCsrBuilder {
68 /// Create a new, empty ``CsrBuilder``.
69 #[new]
70 fn new() -> Self {
71 Self {
72 subject: None,
73 spki: None,
74 extensions: Vec::new(),
75 }
76 }
77
78 /// Set the subject Name from pre-encoded DER bytes.
79 ///
80 /// Accepts any bytes object that is a valid DER-encoded ``Name`` SEQUENCE
81 /// TLV. The bytes from ``Certificate.subject_raw_der`` or
82 /// ``CertificationRequest.subject_raw_der`` are suitable directly:
83 ///
84 /// ```python,ignore
85 /// builder.subject_name(cert.subject_raw_der)
86 /// ```
87 fn subject_name<'py>(slf: Bound<'py, Self>, name_der: &[u8]) -> Bound<'py, Self> {
88 slf.borrow_mut().subject = Some(name_der.to_vec());
89 slf
90 }
91
92 /// Set the SubjectPublicKeyInfo from a :class:`PublicKey` object.
93 ///
94 /// Serializes the key to SPKI DER once and stores the bytes.
95 ///
96 /// ```python,ignore
97 /// builder.public_key(key.public_key())
98 /// ```
99 fn public_key<'py>(
100 slf: Bound<'py, Self>,
101 key: &crate::crypto_keys::PyPublicKey,
102 ) -> PyResult<Bound<'py, Self>> {
103 let der = key
104 .inner
105 .to_der()
106 .map_err(|e| PyValueError::new_err(format!("{e}")))?;
107 slf.borrow_mut().spki = Some(der);
108 Ok(slf)
109 }
110
111 /// Set the SubjectPublicKeyInfo from pre-encoded SPKI DER bytes.
112 ///
113 /// Stores the bytes verbatim — no re-parsing. Suitable for passing
114 /// ``Certificate.subject_public_key_info_der`` or
115 /// ``CertificationRequest.subject_public_key_info_der`` directly:
116 ///
117 /// ```python,ignore
118 /// builder.public_key_der(csr.subject_public_key_info_der)
119 /// ```
120 fn public_key_der<'py>(slf: Bound<'py, Self>, spki_der: &[u8]) -> Bound<'py, Self> {
121 slf.borrow_mut().spki = Some(spki_der.to_vec());
122 slf
123 }
124
125 /// Add an X.509 v3 extension to the ``extensionRequest`` attribute.
126 ///
127 /// ``oid`` is the extension OID in dotted-decimal notation.
128 /// ``critical`` is ``True`` if the extension is critical.
129 /// ``value_der`` is the DER-encoded extension value — the raw bytes that
130 /// ``Certificate.get_extension_value_der(oid)`` returns (i.e., the
131 /// content of the OCTET STRING, *not* the OCTET STRING TLV itself).
132 ///
133 /// All extensions added with this method are collected into a single
134 /// ``extensionRequest`` attribute in the ``CertificationRequestInfo``.
135 ///
136 /// ```python,ignore
137 /// builder.add_extension("2.5.29.17", False, san_der)
138 /// ```
139 fn add_extension<'py>(
140 slf: Bound<'py, Self>,
141 oid: &str,
142 critical: bool,
143 value_der: &[u8],
144 ) -> PyResult<Bound<'py, Self>> {
145 use std::str::FromStr;
146 let oid = ObjectIdentifier::from_str(oid)
147 .map_err(|_| PyValueError::new_err(format!("invalid OID: {oid}")))?;
148 slf.borrow_mut()
149 .extensions
150 .push((oid, critical, value_der.to_vec()));
151 Ok(slf)
152 }
153
154 /// Sign the CSR and return a :class:`CertificationRequest`.
155 ///
156 /// ``key`` is the subject's :class:`PrivateKey`. ``algorithm`` is the
157 /// hash algorithm name — one of ``"sha1"``, ``"sha256"``, ``"sha384"``,
158 /// ``"sha512"`` for RSA and ECDSA keys. For Ed25519 / Ed448 keys the
159 /// argument is ignored (no pre-hash is used).
160 ///
161 /// ``context`` is the ML-DSA context string (FIPS 204 domain separator).
162 /// It defaults to ``None`` (equivalent to an empty context). Ignored for
163 /// non-ML-DSA keys. When ``context`` is a non-empty ``bytes`` object and
164 /// the key is an ML-DSA key, the manual signing path is used:
165 /// ``build_cri`` → ``sign_ml_dsa_with_context`` → ``assemble``.
166 ///
167 /// Both subject name and public key must have been set; a :exc:`ValueError`
168 /// is raised if either is missing.
169 ///
170 /// Raises :exc:`ValueError` on encoding or signing errors.
171 ///
172 /// ```python,ignore
173 /// csr = builder.sign(key, "sha256")
174 /// ml_csr = builder.sign(ml_dsa_key, "sha256", context=b"my-app")
175 /// ```
176 #[pyo3(signature = (key, algorithm, context = None))]
177 fn sign<'py>(
178 &self,
179 py: Python<'py>,
180 key: &PyPrivateKey,
181 algorithm: &str,
182 context: Option<&[u8]>,
183 ) -> PyResult<Bound<'py, PyCsr>> {
184 // Build the Rust-level builder from our stored fields.
185 let mut builder = CsrBuilder::new();
186 if let Some(ref b) = self.subject {
187 builder = builder.subject_name(b);
188 }
189 if let Some(ref b) = self.spki {
190 builder = builder.public_key_der(b);
191 }
192 for (oid, critical, value_bytes) in &self.extensions {
193 builder = builder.add_extension(oid.clone(), *critical, value_bytes);
194 }
195
196 // If a non-empty context is provided and this is an ML-DSA key, take the
197 // manual path: build_cri → sign_ml_dsa_with_context → assemble.
198 let ctx = context.unwrap_or(b"");
199 let is_ml_dsa = matches!(
200 key.inner.key_type(),
201 "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87"
202 );
203 let csr_der = if !ctx.is_empty() && is_ml_dsa {
204 let signer = key.as_signer(algorithm);
205 let sig_alg_der = signer
206 .signature_algorithm_der()
207 .map_err(|e| PyValueError::new_err(format!("{e}")))?;
208 let cri_der = builder
209 .build_cri(&sig_alg_der)
210 .map_err(|e| PyValueError::new_err(format!("{e}")))?;
211 let signature = key
212 .inner
213 .sign_ml_dsa_with_context(&cri_der, ctx)
214 .map_err(|e| PyValueError::new_err(format!("{e}")))?;
215 CsrBuilder::assemble(&cri_der, &sig_alg_der, &signature)
216 .map_err(|e| PyValueError::new_err(format!("{e}")))?
217 } else {
218 // Standard path: delegate to the signer trait object.
219 let signer = key.as_signer(algorithm);
220 builder
221 .sign(&signer)
222 .map_err(|e| PyValueError::new_err(format!("{e}")))?
223 };
224
225 // Parse the assembled DER back into a PyCsr.
226 let py_bytes = PyBytes::new(py, &csr_der);
227 let csr = PyCsr::new_from_der(py, py_bytes)?;
228 Py::new(py, csr).map(|py_csr| py_csr.into_bound(py))
229 }
230
231 fn __repr__(&self) -> String {
232 let subject = self
233 .subject
234 .as_ref()
235 .map(|b| format!("{} bytes", b.len()))
236 .unwrap_or_else(|| "not set".to_string());
237 format!("CsrBuilder(subject={subject})")
238 }
239}