Skip to main content

fatoora_core/
csr.rs

1//! CSR generation and helpers.
2use crate::config::EnvironmentType;
3use base64ct::{Base64, Encoding};
4use ecdsa;
5use fatoora_derive::Validate;
6use java_properties::read;
7use k256::{Secp256k1, ecdsa::SigningKey as K256SigningKey};
8use k256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding as KeyLineEnding};
9use std::{
10    io::Cursor,
11    path::{self, PathBuf},
12    str::FromStr,
13    vec,
14};
15use thiserror::Error;
16use x509_cert::{
17    builder::{Builder, RequestBuilder},
18    der::{
19        Decode, Encode, EncodePem, Error as DerError, Length, Result as DerResult, Writer, asn1,
20        pem::LineEnding as CsrLineEnding,
21    },
22    ext::{
23        AsExtension, Extension,
24        pkix::{SubjectAltName, name::GeneralName},
25    },
26    name,
27    request::CertReq,
28};
29
30/// Errors that can occur while generating or validating CSRs.
31#[derive(Debug, Error)]
32pub enum CsrError {
33    #[error("failed to open CSR config file '{path}': {source}")]
34    Io {
35        path: PathBuf,
36        #[source]
37        source: std::io::Error,
38    },
39
40    #[error("failed to parse CSR properties from '{path}': {source}")]
41    PropertiesRead {
42        path: PathBuf,
43        #[source]
44        source: java_properties::PropertiesError,
45    },
46
47    #[error("missing required CSR property '{key}' in file '{path}'")]
48    MissingProperty { path: PathBuf, key: String },
49
50    #[error("invalid subject distinguished name constructed from provided fields: {message}")]
51    InvalidSubject { message: String },
52
53    #[error("invalid Subject Alternative Name (SAN) from fields: {message}")]
54    InvalidSan { message: String },
55
56    #[error("failed to construct CSR request: {message}")]
57    RequestBuild { message: String },
58
59    #[error("failed adding CSR extension '{which}': {message}")]
60    AddExtension {
61        which: &'static str,
62        message: String,
63    },
64
65    #[error("failed to build CSR: {message}")]
66    CsrBuild { message: String },
67
68    #[error("failed to decode signing key: {message}")]
69    KeyDecode { message: String },
70
71    #[error("failed to encode signing key: {message}")]
72    KeyEncode { message: String },
73
74    #[error("failed DER encoding for {context}: {source}")]
75    DerEncode {
76        context: &'static str,
77        #[source]
78        source: DerError,
79    },
80
81    #[error("validation error: {message}")]
82    Validation { message: String },
83}
84
85struct TemplateNameExtension(pub asn1::OctetString);
86
87impl const_oid::AssociatedOid for TemplateNameExtension {
88    const OID: const_oid::ObjectIdentifier =
89        const_oid::ObjectIdentifier::new_unwrap("1.3.6.1.4.1.311.20.2");
90}
91
92impl Encode for TemplateNameExtension {
93    fn encoded_len(&self) -> DerResult<Length> {
94        self.0.encoded_len()
95    }
96    fn encode(&self, encoder: &mut impl Writer) -> DerResult<()> {
97        self.0.encode(encoder)
98    }
99}
100
101impl AsExtension for TemplateNameExtension {
102    fn critical(&self, _name: &name::Name, _exts: &[Extension]) -> bool {
103        false
104    }
105}
106
107impl EnvironmentType {
108    const fn as_template_bytes(&self) -> &'static [u8] {
109        match self {
110            EnvironmentType::NonProduction => b"TSTZATCA-Code-Signing",
111            EnvironmentType::Simulation => b"PREZATCA-Code-Signing",
112            EnvironmentType::Production => b"ZATCA-Code-Signing",
113        }
114    }
115
116    fn to_extension(self) -> Result<TemplateNameExtension, CsrError> {
117        let bytes = self.as_template_bytes();
118        let os = asn1::OctetString::new(bytes).map_err(|e| CsrError::RequestBuild {
119            message: format!("invalid template name bytes for extension: {e}"),
120        })?;
121        Ok(TemplateNameExtension(os))
122    }
123}
124
125/// Wrapper for CSR signing keys.
126#[derive(Debug, Clone)]
127pub struct SigningKey {
128    inner: K256SigningKey,
129}
130
131impl SigningKey {
132    pub fn generate() -> Self {
133        Self {
134            inner: ecdsa::SigningKey::<Secp256k1>::generate(),
135        }
136    }
137
138    pub fn from_der(der: &[u8]) -> Result<Self, CsrError> {
139        let inner = K256SigningKey::from_pkcs8_der(der).map_err(|e| CsrError::KeyDecode {
140            message: e.to_string(),
141        })?;
142        Ok(Self { inner })
143    }
144
145    pub fn from_pem(pem: &str) -> Result<Self, CsrError> {
146        let inner = K256SigningKey::from_pkcs8_pem(pem).map_err(|e| CsrError::KeyDecode {
147            message: e.to_string(),
148        })?;
149        Ok(Self { inner })
150    }
151
152    pub fn to_der(&self) -> Result<Vec<u8>, CsrError> {
153        let doc = self
154            .inner
155            .to_pkcs8_der()
156            .map_err(|e: k256::pkcs8::Error| CsrError::KeyEncode {
157                message: e.to_string(),
158            })?;
159        Ok(doc.as_bytes().to_vec())
160    }
161
162    pub fn to_pem(&self) -> Result<String, CsrError> {
163        let pem = self
164            .inner
165            .to_pkcs8_pem(KeyLineEnding::LF)
166            .map_err(|e: k256::pkcs8::Error| CsrError::KeyEncode {
167                message: e.to_string(),
168            })?;
169        Ok(pem.to_string())
170    }
171
172    pub(crate) fn inner(&self) -> &K256SigningKey {
173        &self.inner
174    }
175}
176
177/// Wrapper for certificate signing requests.
178#[derive(Debug, Clone)]
179pub struct Csr {
180    inner: CertReq,
181}
182
183impl Csr {
184    pub fn from_der(der: &[u8]) -> Result<Self, CsrError> {
185        let inner = CertReq::from_der(der).map_err(|e| CsrError::DerEncode {
186            context: "certificate request (DER)",
187            source: e,
188        })?;
189        Ok(Self { inner })
190    }
191
192    pub fn to_der(&self) -> Result<Vec<u8>, CsrError> {
193        let der_bytes = self.inner.to_der().map_err(|e| CsrError::DerEncode {
194            context: "certificate request",
195            source: e,
196        })?;
197        Ok(der_bytes)
198    }
199
200    pub fn to_pem(&self) -> Result<String, CsrError> {
201        let pem = self
202            .inner
203            .to_pem(CsrLineEnding::LF)
204            .map_err(|e| CsrError::DerEncode {
205                context: "certificate request (PEM)",
206                source: e,
207            })?;
208        Ok(pem)
209    }
210
211    pub fn to_base64(&self) -> Result<String, CsrError> {
212        let der_bytes = self.to_der()?;
213        Ok(Base64::encode_string(&der_bytes))
214    }
215
216    pub fn to_pem_base64(&self) -> Result<String, CsrError> {
217        let pem = self.to_pem()?;
218        Ok(Base64::encode_string(pem.as_bytes()))
219    }
220
221    pub fn subject_string(&self) -> String {
222        self.inner.info.subject.to_string()
223    }
224
225    pub fn extension_values_der(&self) -> Vec<Vec<u8>> {
226        self.inner
227            .info
228            .attributes
229            .iter()
230            .flat_map(|attr| attr.values.iter())
231            .filter_map(|value| value.to_der().ok())
232            .collect()
233    }
234
235    #[allow(dead_code)]
236    pub(crate) fn inner(&self) -> &CertReq {
237        &self.inner
238    }
239}
240
241/// CSR properties parsed from the SDK properties file.
242///
243/// # Examples
244/// ```rust,no_run
245/// use fatoora_core::config::EnvironmentType;
246/// use fatoora_core::csr::CsrProperties;
247///
248/// let props = CsrProperties::from_properties_str("csr.common.name=example")?;
249/// let key = fatoora_core::csr::SigningKey::generate();
250/// let csr = props.build(&key, EnvironmentType::NonProduction)?;
251/// # let _ = csr;
252/// use fatoora_core::csr::CsrError;
253/// # Ok::<(), CsrError>(())
254/// ```
255#[allow(dead_code)]
256#[derive(Validate, Debug, Clone, PartialEq, Eq, Hash)]
257#[validate_error(CsrError)]
258#[validate(non_empty, no_special_chars)]
259pub struct CsrProperties {
260    common_name: String,
261    serial_number: String,
262    organization_identifier: String,
263    organization_unit_name: String,
264    organization_name: String,
265    #[validate(is_country_code)]
266    country_name: String,
267    invoice_type: String,
268    location_address: String,
269    industry_business_category: String,
270}
271
272impl CsrProperties {
273    fn generate_subject(&self) -> Result<name::Name, CsrError> {
274        name::Name::from_str(&format!(
275            "C={},OU={},O={},CN={}",
276            &self.country_name,
277            &self.organization_unit_name,
278            &self.organization_name,
279            &self.common_name
280        ))
281        .map_err(|e| CsrError::InvalidSubject {
282            message: e.to_string(),
283        })
284    }
285
286    fn generate_template_name_extension(
287        &self,
288        env: EnvironmentType,
289    ) -> Result<TemplateNameExtension, CsrError> {
290        env.to_extension()
291    }
292
293    fn generate_san_extension(&self) -> Result<SubjectAltName, CsrError> {
294        let name = name::Name::from_str(&format!(
295            "sn={},uid={},title={},registeredAddress={},businessCategory={}",
296            &self.serial_number,
297            &self.organization_identifier,
298            &self.invoice_type,
299            &self.location_address,
300            &self.industry_business_category
301        ))
302        .map_err(|e| CsrError::InvalidSan {
303            message: e.to_string(),
304        })?;
305        let dir_name = GeneralName::DirectoryName(name);
306        Ok(SubjectAltName::from(vec![dir_name]))
307    }
308
309    /// Build a CSR using the provided signer.
310    ///
311    /// # Errors
312    /// Returns [`CsrError`] when subject or extension generation fails.
313    pub fn build(&self, signer: &SigningKey, env: EnvironmentType) -> Result<Csr, CsrError> {
314        let subject = self.generate_subject()?;
315        let asn1_extension = self.generate_template_name_extension(env)?;
316        let san_extension = self.generate_san_extension()?;
317
318        let mut csr_builder = RequestBuilder::new(subject).map_err(|e| CsrError::RequestBuild {
319            message: e.to_string(),
320        })?;
321        csr_builder
322            .add_extension(&asn1_extension)
323            .map_err(|e| CsrError::AddExtension {
324                which: "TemplateName",
325                message: e.to_string(),
326            })?;
327        csr_builder
328            .add_extension(&san_extension)
329            .map_err(|e| CsrError::AddExtension {
330                which: "SubjectAltName",
331                message: e.to_string(),
332            })?;
333        csr_builder
334            .build::<_, ecdsa::der::Signature<_>>(signer.inner())
335            .map_err(|e| CsrError::CsrBuild {
336                message: e.to_string(),
337            })
338            .map(|inner| Csr { inner })
339    }
340
341    /// Parse a CSR properties string.
342    ///
343    /// # Errors
344    /// Returns [`CsrError`] when the properties cannot be read or required fields are missing.
345    pub fn from_properties_str(properties: &str) -> Result<CsrProperties, CsrError> {
346        let pathbuf = path::PathBuf::from("<properties>");
347        let cursor = Cursor::new(properties.as_bytes());
348        let dst_map = read(cursor).map_err(|e| CsrError::PropertiesRead {
349            path: pathbuf.clone(),
350            source: e,
351        })?;
352
353        let req = |key: &str| -> Result<String, CsrError> {
354            dst_map
355                .get(key)
356                .map(|s| s.to_string())
357                .ok_or_else(|| CsrError::MissingProperty {
358                    path: pathbuf.clone(),
359                    key: key.to_string(),
360                })
361        };
362
363        let csr = CsrProperties::new(
364            req("csr.common.name")?,
365            req("csr.serial.number")?,
366            req("csr.organization.identifier")?,
367            req("csr.organization.unit.name")?,
368            req("csr.organization.name")?,
369            req("csr.country.name")?,
370            req("csr.invoice.type")?,
371            req("csr.location.address")?,
372            req("csr.industry.business.category")?,
373        )?;
374
375        Ok(csr)
376    }
377
378    /// Parse a CSR properties string.
379    ///
380    /// # Errors
381    /// Returns [`CsrError`] when the properties cannot be read or required fields are missing.
382    pub fn parse_csr_config(properties: &str) -> Result<CsrProperties, CsrError> {
383        Self::from_properties_str(properties)
384    }
385
386    /// Parse a CSR properties file.
387    ///
388    /// # Errors
389    /// Returns [`CsrError`] when the file cannot be read or required fields are missing.
390    pub fn parse_csr_config_file(csr_path: impl AsRef<path::Path>) -> Result<CsrProperties, CsrError> {
391        let path = csr_path.as_ref();
392        let pathbuf = path.to_path_buf();
393        let contents = std::fs::read_to_string(path).map_err(|e| CsrError::Io {
394            path: pathbuf.clone(),
395            source: e,
396        })?;
397        let csr = CsrProperties::from_properties_str(&contents)?;
398        Ok(csr)
399    }
400}
401
402impl From<String> for CsrError {
403    fn from(message: String) -> Self {
404        CsrError::Validation { message }
405    }
406}