1use std::sync::Arc;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use tracing::{debug, info, warn};
16
17#[derive(Debug, Error)]
19pub enum IssuanceError {
20 #[error("invalid CSR: {0}")]
22 InvalidCsr(String),
23
24 #[error("key too small: {algorithm} {bits}-bit (minimum {min_bits}-bit required)")]
26 KeyTooSmall {
27 algorithm: String,
28 bits: u32,
29 min_bits: u32,
30 },
31
32 #[error("validity period {requested_days} days exceeds maximum {max_days} days")]
34 ValidityTooLong { requested_days: u32, max_days: u32 },
35
36 #[error("missing required extension: {0}")]
38 MissingExtension(String),
39
40 #[error("unknown enrollment profile: {0}")]
42 UnknownProfile(String),
43
44 #[error("signing failed: {0}")]
46 SigningError(String),
47
48 #[error("storage error: {0}")]
50 StorageError(String),
51
52 #[error("algorithm not allowed: {algorithm} not in profile '{profile}'")]
54 AlgorithmNotAllowed { algorithm: String, profile: String },
55
56 #[error("prohibited DN attribute: {name} ({oid})")]
58 ProhibitedDnAttribute { oid: String, name: String },
59}
60
61impl IssuanceError {
62 pub fn is_client_error(&self) -> bool {
80 match self {
81 Self::InvalidCsr(_)
83 | Self::KeyTooSmall { .. }
84 | Self::AlgorithmNotAllowed { .. }
85 | Self::ProhibitedDnAttribute { .. } => true,
86 Self::SigningError(_)
88 | Self::StorageError(_)
89 | Self::MissingExtension(_)
90 | Self::ValidityTooLong { .. }
91 | Self::UnknownProfile(_) => false,
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct IssuanceResult {
99 pub certificate_der: Vec<u8>,
101 pub serial_number: String,
103 pub subject_dn: String,
105 pub not_before: DateTime<Utc>,
107 pub not_after: DateTime<Utc>,
109}
110
111pub enum CaSigningKey<'a> {
116 Pem(&'a [u8]),
118 Hsm {
120 context: &'a Arc<kipuka_hsm::HsmContext>,
122 key_label: &'a str,
124 },
125}
126
127pub enum ResolvedSigningKey {
134 Pem(Vec<u8>),
136 Hsm {
138 context: std::sync::Arc<kipuka_hsm::HsmContext>,
140 key_label: String,
142 },
143}
144
145impl ResolvedSigningKey {
146 pub fn as_signing_key(&self) -> CaSigningKey<'_> {
148 match self {
149 ResolvedSigningKey::Pem(pem) => CaSigningKey::Pem(pem),
150 ResolvedSigningKey::Hsm { context, key_label } => {
151 CaSigningKey::Hsm { context, key_label }
152 }
153 }
154 }
155}
156
157pub async fn resolve_signing_key(
163 ca_cfg: &crate::config::CaConfig,
164 hsm: Option<&std::sync::Arc<kipuka_hsm::HsmContext>>,
165) -> Result<ResolvedSigningKey, crate::error::KipukaError> {
166 if ca_cfg.is_hsm_backed() {
167 let hsm_ctx = hsm.ok_or_else(|| {
168 crate::error::KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into())
169 })?;
170 let pkcs11_uri = ca_cfg.pkcs11_uri.as_deref().ok_or_else(|| {
171 crate::error::KipukaError::Ca(
172 "CA marked as HSM-backed but pkcs11_uri not configured".into(),
173 )
174 })?;
175 let key_label = parse_pkcs11_object_label(pkcs11_uri)
176 .map_err(|e| crate::error::KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
177 Ok(ResolvedSigningKey::Hsm {
178 context: std::sync::Arc::clone(hsm_ctx),
179 key_label,
180 })
181 } else {
182 let pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
183 crate::error::KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
184 })?;
185 Ok(ResolvedSigningKey::Pem(pem))
186 }
187}
188
189pub fn resolve_signing_key_sync(
194 ca_cfg: &crate::config::CaConfig,
195 hsm: Option<&std::sync::Arc<kipuka_hsm::HsmContext>>,
196) -> Result<ResolvedSigningKey, crate::error::KipukaError> {
197 if ca_cfg.is_hsm_backed() {
198 let hsm_ctx = hsm.ok_or_else(|| {
199 crate::error::KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into())
200 })?;
201 let pkcs11_uri = ca_cfg.pkcs11_uri.as_deref().ok_or_else(|| {
202 crate::error::KipukaError::Ca(
203 "CA marked as HSM-backed but pkcs11_uri not configured".into(),
204 )
205 })?;
206 let key_label = parse_pkcs11_object_label(pkcs11_uri)
207 .map_err(|e| crate::error::KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
208 Ok(ResolvedSigningKey::Hsm {
209 context: std::sync::Arc::clone(hsm_ctx),
210 key_label,
211 })
212 } else {
213 let pem = std::fs::read(&ca_cfg.key_file).map_err(|e| {
214 crate::error::KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
215 })?;
216 Ok(ResolvedSigningKey::Pem(pem))
217 }
218}
219
220pub(crate) fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
230 let path = uri
231 .strip_prefix("pkcs11:")
232 .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
233
234 for part in path.split(';') {
235 if let Some((key, value)) = part.split_once('=')
236 && key == "object"
237 {
238 return pkcs11_percent_decode(value);
239 }
240 }
241
242 Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
243}
244
245fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
247 let mut result = Vec::with_capacity(s.len());
248 let bytes = s.as_bytes();
249 let mut i = 0;
250 while i < bytes.len() {
251 if bytes[i] == b'%' && i + 2 < bytes.len() {
252 let hi = hex_digit(bytes[i + 1])
253 .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
254 let lo = hex_digit(bytes[i + 2])
255 .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
256 result.push((hi << 4) | lo);
257 i += 3;
258 } else {
259 result.push(bytes[i]);
260 i += 1;
261 }
262 }
263 String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
264}
265
266fn hex_digit(b: u8) -> Option<u8> {
267 match b {
268 b'0'..=b'9' => Some(b - b'0'),
269 b'a'..=b'f' => Some(b - b'a' + 10),
270 b'A'..=b'F' => Some(b - b'A' + 10),
271 _ => None,
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct EnrollmentProfile {
281 pub name: String,
283 pub max_validity_days: u32,
285 #[serde(default)]
287 pub max_validity_seconds: Option<u64>,
288 #[serde(default)]
290 pub max_not_after: Option<DateTime<Utc>>,
291 pub key_usage: Vec<String>,
293 pub extended_key_usage: Vec<String>,
295 pub include_ski: bool,
297 pub include_aki: bool,
299 pub min_rsa_bits: u32,
301 pub min_ecdsa_curve: String,
303 pub ct_enabled: bool,
305
306 #[serde(default)]
309 pub certificate_policies: Vec<String>,
310
311 #[serde(default)]
316 pub allowed_ml_dsa_levels: Vec<String>,
317
318 #[serde(default)]
322 pub allowed_ml_kem_levels: Vec<String>,
323
324 #[serde(default)]
327 pub allow_composite_ml_dsa: bool,
328
329 #[serde(default)]
334 pub require_dual_cert: bool,
335
336 #[serde(default)]
357 pub must_staple: bool,
358}
359
360impl Default for EnrollmentProfile {
361 fn default() -> Self {
362 Self {
363 name: "default".into(),
364 max_validity_days: cab_forum_max_validity_days(),
365 max_validity_seconds: None,
366 max_not_after: None,
367 key_usage: vec!["digitalSignature".into(), "keyEncipherment".into()],
368 extended_key_usage: vec!["serverAuth".into()],
369 include_ski: true,
370 include_aki: true,
371 min_rsa_bits: 2048,
372 min_ecdsa_curve: "P-256".into(),
373 ct_enabled: false,
374 certificate_policies: vec!["2.23.140.1.2.1".into()],
375 allowed_ml_dsa_levels: vec!["ml-dsa-44".into(), "ml-dsa-65".into(), "ml-dsa-87".into()],
377 allowed_ml_kem_levels: vec![
378 "ml-kem-512".into(),
379 "ml-kem-768".into(),
380 "ml-kem-1024".into(),
381 ],
382 allow_composite_ml_dsa: true,
383 require_dual_cert: false,
384 must_staple: false,
385 }
386 }
387}
388
389pub const TLS_FEATURE_MUST_STAPLE_DER: &[u8] = &[0x30, 0x03, 0x02, 0x01, 0x05];
401
402pub const OID_TLS_FEATURE: &str = "1.3.6.1.5.5.7.1.24";
406
407const OID_TLS_FEATURE_COMPONENTS: &[u32] = &[1, 3, 6, 1, 5, 5, 7, 1, 24];
409
410pub fn issue_certificate(
430 csr_der: &[u8],
431 profile: &EnrollmentProfile,
432 ca_cert_der: &[u8],
433 signing_key: CaSigningKey<'_>,
434 hash_algorithm: &str,
435 ocsp_url: Option<&str>,
436 crl_url: Option<&str>,
437) -> Result<IssuanceResult, IssuanceError> {
438 verify_csr_pop(csr_der)?;
446
447 validate_csr(csr_der)?;
449 issue_verified_request(
450 csr_der,
451 profile,
452 ca_cert_der,
453 signing_key,
454 hash_algorithm,
455 ocsp_url,
456 crl_url,
457 )
458}
459
460fn issue_verified_request(
462 csr_der: &[u8],
463 profile: &EnrollmentProfile,
464 ca_cert_der: &[u8],
465 signing_key: CaSigningKey<'_>,
466 hash_algorithm: &str,
467 ocsp_url: Option<&str>,
468 crl_url: Option<&str>,
469) -> Result<IssuanceResult, IssuanceError> {
470 check_subject_dn_compliance(csr_der)?;
472
473 check_key_size(csr_der, profile)?;
475
476 check_validity_period(profile)?;
478
479 check_required_extensions(profile)?;
481
482 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
484 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
485
486 let csr_subject_der = csr
487 .certification_request_info
488 .subject
489 .to_der()
490 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR subject encode failed: {e}")))?;
491
492 let csr_spki_der = csr
493 .certification_request_info
494 .subject_pkinfo
495 .to_der()
496 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR SPKI encode failed: {e}")))?;
497
498 let ca_cert = synta_certificate::Certificate::from_der(ca_cert_der)
500 .map_err(|e| IssuanceError::SigningError(format!("CA cert parse failed: {e}")))?;
501
502 let ca_subject_der = ca_cert.tbs_certificate.subject.0;
503 let ca_spki_der = ca_cert
504 .tbs_certificate
505 .subject_public_key_info
506 .to_der()
507 .map_err(|e| IssuanceError::SigningError(format!("CA SPKI encode failed: {e}")))?;
508
509 let pem_key: Option<synta_certificate::BackendPrivateKey>;
511 match &signing_key {
512 CaSigningKey::Pem(pem) => {
513 pem_key = Some(
514 synta_certificate::BackendPrivateKey::from_pem(pem, None).map_err(|e| {
515 IssuanceError::SigningError(format!("CA key parse failed: {e}"))
516 })?,
517 );
518 debug!("loaded CA private key from PEM");
519 }
520 CaSigningKey::Hsm { key_label, .. } => {
521 pem_key = None;
522 debug!(key_label = %key_label, "using HSM-backed CA signing key");
523 }
524 }
525
526 let serial_bytes = generate_serial_bytes();
529 let serial = synta::Integer::from_unsigned_bytes(&serial_bytes);
530 let serial_hex = hex::encode(&serial_bytes);
531
532 let now = Utc::now();
534 let mut not_after_chrono = now + chrono::Duration::days(profile.max_validity_days as i64);
535 if let Some(seconds) = profile.max_validity_seconds {
536 let seconds = i64::try_from(seconds)
537 .map_err(|_| IssuanceError::SigningError("validity overflow".into()))?;
538 not_after_chrono = not_after_chrono.min(
539 now.checked_add_signed(chrono::Duration::seconds(seconds))
540 .ok_or_else(|| IssuanceError::SigningError("validity overflow".into()))?,
541 );
542 }
543 if let Some(limit) = profile.max_not_after {
544 not_after_chrono = not_after_chrono.min(limit);
545 }
546 crate::auth::certificate::valid_now(ca_cert_der).map_err(IssuanceError::SigningError)?;
547 not_after_chrono = not_after_chrono.min(
548 crate::auth::certificate::time(&ca_cert.tbs_certificate.validity.not_after)
549 .map_err(IssuanceError::SigningError)?,
550 );
551 if not_after_chrono <= now {
552 return Err(IssuanceError::SigningError(
553 "empty certificate validity period".into(),
554 ));
555 }
556
557 let not_before_time = chrono_to_synta_time(now)
558 .map_err(|e| IssuanceError::SigningError(format!("not_before time conversion: {e}")))?;
559 let not_after_time = chrono_to_synta_time(not_after_chrono)
560 .map_err(|e| IssuanceError::SigningError(format!("not_after time conversion: {e}")))?;
561
562 debug!(
564 profile = %profile.name,
565 max_days = profile.max_validity_days,
566 ct = profile.ct_enabled,
567 must_staple = profile.must_staple,
568 "building certificate from CSR"
569 );
570
571 let mut builder = synta_certificate::CertificateBuilder::new()
572 .issuer_name(ca_subject_der)
573 .subject_name(&csr_subject_der)
574 .public_key_der(&csr_spki_der)
575 .serial_number(serial)
576 .not_valid_before(not_before_time)
577 .not_valid_after(not_after_time);
578
579 for (oid, critical, value) in requested_extensions(csr_der)? {
582 if oid.components() == synta_certificate::oids::SUBJECT_ALT_NAME {
583 builder = builder.add_extension(oid, critical, &value);
584 }
585 }
586
587 if let Some(bc_der) = synta_certificate::encode_basic_constraints(false, None) {
589 builder =
590 builder.add_extension_oid(synta_certificate::oids::BASIC_CONSTRAINTS, true, &bc_der);
591 }
592
593 let ku_bits = profile_key_usage_bits(profile);
595 if let Some(ku_der) = synta_certificate::encode_key_usage(ku_bits) {
596 builder = builder.add_extension_oid(synta_certificate::oids::KEY_USAGE, true, &ku_der);
597 }
598
599 let eku_der = profile_extended_key_usage(profile);
601 if let Some(eku) = eku_der {
602 builder =
603 builder.add_extension_oid(synta_certificate::oids::EXTENDED_KEY_USAGE, false, &eku);
604 }
605
606 if !profile.certificate_policies.is_empty() {
608 let cp_der = encode_certificate_policies(&profile.certificate_policies)?;
609 builder = builder.add_extension_oid(
610 synta_certificate::oids::CERTIFICATE_POLICIES,
611 false,
612 &cp_der,
613 );
614 }
615
616 if profile.include_ski {
618 let hasher = synta_certificate::OpensslKeyIdHasher;
619 if let Some(ski_der) = synta_certificate::encode_subject_key_identifier(
620 &csr_spki_der,
621 synta_certificate::KeyIdMethod::Rfc5280Sha1,
622 &hasher,
623 ) {
624 builder = builder.add_extension_oid(
625 synta_certificate::oids::SUBJECT_KEY_IDENTIFIER,
626 false,
627 &ski_der,
628 );
629 }
630 }
631
632 if profile.include_aki {
634 let hasher = synta_certificate::OpensslKeyIdHasher;
635 if let Some(aki_der) = synta_certificate::encode_authority_key_identifier(
636 &ca_spki_der,
637 synta_certificate::KeyIdMethod::Rfc5280Sha1,
638 &hasher,
639 ) {
640 builder = builder.add_extension_oid(
641 synta_certificate::oids::AUTHORITY_KEY_IDENTIFIER,
642 false,
643 &aki_der,
644 );
645 }
646 }
647
648 if let Some(url) = ocsp_url {
650 let aia_der = encode_aia_extension(url)?;
651 builder = builder.add_extension_oid(
652 synta_certificate::oids::AUTHORITY_INFO_ACCESS,
653 false,
654 &aia_der,
655 );
656 }
657
658 if let Some(url) = crl_url {
660 let cdp_der = encode_cdp_extension(url)?;
661 builder = builder.add_extension_oid(
662 synta_certificate::oids::CRL_DISTRIBUTION_POINTS,
663 false,
664 &cdp_der,
665 );
666 }
667
668 if profile.must_staple {
670 debug!(
671 oid = OID_TLS_FEATURE,
672 "including TLS Feature Extension (must-staple) per RFC 7633 §4"
673 );
674 builder = builder.add_extension_oid(
675 OID_TLS_FEATURE_COMPONENTS,
676 false,
677 TLS_FEATURE_MUST_STAPLE_DER,
678 );
679 }
680
681 let cert_der = match &signing_key {
687 CaSigningKey::Pem(_) => {
688 use synta_certificate::PrivateKey as _;
689 let ca_pkey = pem_key.as_ref().expect("PEM key loaded in step 7");
690 let effective_hash = if hash_algorithm == "none" {
691 ""
692 } else {
693 hash_algorithm
694 };
695 let signer = ca_pkey.as_signer(effective_hash);
696 builder.sign(&signer).map_err(|e| {
697 IssuanceError::SigningError(format!("certificate signing failed: {e}"))
698 })?
699 }
700 CaSigningKey::Hsm { context, key_label } => {
701 let hsm_signer = HsmCertificateSigner {
703 context,
704 key_label,
705 hash_algorithm,
706 };
707 builder.sign(&hsm_signer).map_err(|e| {
708 IssuanceError::SigningError(format!("HSM certificate signing failed: {e}"))
709 })?
710 }
711 };
712
713 let subject_dn = synta_certificate::format_dn(&csr_subject_der);
715
716 info!(
717 serial = %serial_hex,
718 profile = %profile.name,
719 subject = %subject_dn,
720 not_after = %not_after_chrono,
721 cert_len = cert_der.len(),
722 "certificate issued"
723 );
724
725 Ok(IssuanceResult {
726 certificate_der: cert_der,
727 serial_number: serial_hex,
728 subject_dn,
729 not_before: now,
730 not_after: not_after_chrono,
731 })
732}
733
734fn generate_serial_bytes() -> Vec<u8> {
740 use rand::Rng;
741 let mut bytes = vec![0u8; 20];
742 rand::rng().fill_bytes(&mut bytes);
743 bytes[0] &= 0x7F;
745 if bytes[0] == 0 {
747 bytes[0] = 1;
748 }
749 bytes
750}
751
752fn chrono_to_synta_time(dt: DateTime<Utc>) -> Result<synta_certificate::Time, String> {
758 let year = dt.format("%Y").to_string().parse::<u16>().unwrap_or(2024);
759 let month = dt.format("%m").to_string().parse::<u8>().unwrap_or(1);
760 let day = dt.format("%d").to_string().parse::<u8>().unwrap_or(1);
761 let hour = dt.format("%H").to_string().parse::<u8>().unwrap_or(0);
762 let minute = dt.format("%M").to_string().parse::<u8>().unwrap_or(0);
763 let second = dt.format("%S").to_string().parse::<u8>().unwrap_or(0);
764
765 if year < 2050 {
766 let utc_time = synta::UtcTime::new(year, month, day, hour, minute, second)
767 .map_err(|e| format!("UtcTime creation failed: {e}"))?;
768 Ok(synta_certificate::Time::UtcTime(utc_time))
769 } else {
770 let gen_time = synta::GeneralizedTime::new(year, month, day, hour, minute, second, None)
771 .map_err(|e| format!("GeneralizedTime creation failed: {e}"))?;
772 Ok(synta_certificate::Time::GeneralTime(gen_time))
773 }
774}
775
776fn profile_key_usage_bits(profile: &EnrollmentProfile) -> u16 {
778 use synta_certificate::{
779 KEY_USAGE_DATA_ENCIPHERMENT, KEY_USAGE_DIGITAL_SIGNATURE, KEY_USAGE_KEY_AGREEMENT,
780 KEY_USAGE_KEY_ENCIPHERMENT, KEY_USAGE_NON_REPUDIATION,
781 };
782
783 let mut bits: u16 = 0;
784 for ku in &profile.key_usage {
785 match ku.as_str() {
786 "digitalSignature" => bits |= 1 << KEY_USAGE_DIGITAL_SIGNATURE,
787 "nonRepudiation" | "contentCommitment" => bits |= 1 << KEY_USAGE_NON_REPUDIATION,
788 "keyEncipherment" => bits |= 1 << KEY_USAGE_KEY_ENCIPHERMENT,
789 "dataEncipherment" => bits |= 1 << KEY_USAGE_DATA_ENCIPHERMENT,
790 "keyAgreement" => bits |= 1 << KEY_USAGE_KEY_AGREEMENT,
791 other => {
792 warn!(key_usage = %other, "unknown key usage flag in profile; skipping");
793 }
794 }
795 }
796 bits
797}
798
799fn profile_extended_key_usage(profile: &EnrollmentProfile) -> Option<Vec<u8>> {
801 if profile.extended_key_usage.is_empty() {
802 return None;
803 }
804
805 let mut builder = synta_certificate::ExtendedKeyUsageBuilder::new();
806 for eku in &profile.extended_key_usage {
807 builder = match eku.as_str() {
808 "serverAuth" => builder.server_auth(),
809 "clientAuth" => builder.client_auth(),
810 "codeSigning" => builder.code_signing(),
811 "emailProtection" => builder.email_protection(),
812 "timeStamping" => builder.time_stamping(),
813 "OCSPSigning" | "ocspSigning" => builder.ocsp_signing(),
814 other => {
815 warn!(eku = %other, "unknown extended key usage in profile; skipping");
816 builder
817 }
818 };
819 }
820 builder.build().ok()
821}
822
823fn validate_csr(csr_der: &[u8]) -> Result<(), IssuanceError> {
825 if csr_der.is_empty() {
826 return Err(IssuanceError::InvalidCsr("empty CSR".into()));
827 }
828
829 if csr_der[0] != 0x30 {
831 return Err(IssuanceError::InvalidCsr(
832 "does not start with ASN.1 SEQUENCE".into(),
833 ));
834 }
835
836 use synta_certificate::SignatureVerifier;
837 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
838 .map_err(|e| IssuanceError::InvalidCsr(format!("PKCS#10 parse failed: {e}")))?;
839 let encode_error = |e| IssuanceError::InvalidCsr(format!("CSR encoding: {e}"));
840 let cri = csr
841 .certification_request_info
842 .to_der()
843 .map_err(encode_error)?;
844 let algorithm = csr.signature_algorithm.to_der().map_err(encode_error)?;
845 let spki = csr
846 .certification_request_info
847 .subject_pkinfo
848 .to_der()
849 .map_err(encode_error)?;
850 synta_certificate::default_signature_verifier()
851 .verify_certificate_signature(&cri, &algorithm, csr.signature.as_bytes(), &spki)
852 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR proof of possession failed: {e}")))?;
853
854 debug!(len = csr_der.len(), "CSR structure validated");
855 Ok(())
856}
857
858fn check_subject_dn_compliance(csr_der: &[u8]) -> Result<(), IssuanceError> {
860 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
861 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
862
863 let subject_der = csr
864 .certification_request_info
865 .subject
866 .to_der()
867 .map_err(|e| IssuanceError::InvalidCsr(format!("subject DN encode failed: {e}")))?;
868
869 let attrs = synta_certificate::parse_name_attrs(&subject_der);
870 for (oid, _value) in &attrs {
871 if oid == "2.5.4.11" {
872 return Err(IssuanceError::ProhibitedDnAttribute {
873 oid: oid.clone(),
874 name: "organizationalUnitName".into(),
875 });
876 }
877 }
878
879 Ok(())
880}
881
882fn check_key_size(csr_der: &[u8], profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
884 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der).map_err(|e| {
886 IssuanceError::InvalidCsr(format!("CSR parse failed in key size check: {e}"))
887 })?;
888
889 let spki = &csr.certification_request_info.subject_pkinfo;
890 let alg_oid = spki.algorithm.algorithm.components();
891 let key_bits = spki.subject_public_key.bit_len();
892
893 let pk_info = synta_certificate::decode_public_key_info(
894 &spki.algorithm.algorithm,
895 spki.algorithm.parameters.as_ref(),
896 spki.subject_public_key.as_bytes(),
897 key_bits,
898 );
899
900 match &pk_info {
901 synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
902 debug!(
903 algorithm = "RSA",
904 key_bits = bit_count,
905 "CSR public key info"
906 );
907 if (*bit_count as u32) < profile.min_rsa_bits {
908 return Err(IssuanceError::KeyTooSmall {
909 algorithm: "RSA".into(),
910 bits: *bit_count as u32,
911 min_bits: profile.min_rsa_bits,
912 });
913 }
914 }
915 synta_certificate::PublicKeyInfo::Ec {
916 bit_count,
917 curve_nist_name,
918 ..
919 } => {
920 let curve_name = curve_nist_name.unwrap_or("unknown");
921 debug!(
922 algorithm = "EC",
923 curve = curve_name,
924 key_bits = bit_count,
925 "CSR public key info"
926 );
927 let min_bits: usize = match profile.min_ecdsa_curve.as_str() {
928 "P-256" => 256,
929 "P-384" => 384,
930 "P-521" => 521,
931 _ => 256,
932 };
933 if *bit_count < min_bits {
934 return Err(IssuanceError::KeyTooSmall {
935 algorithm: format!("EC {curve_name}"),
936 bits: *bit_count as u32,
937 min_bits: min_bits as u32,
938 });
939 }
940 }
941 synta_certificate::PublicKeyInfo::Unknown {
942 alg_name,
943 bit_count,
944 ..
945 } => {
946 const PQC_ALGORITHMS: &[(&str, &str, &str)] = &[
948 ("2.16.840.1.101.3.4.3.17", "ML-DSA-44", "ml-dsa-44"),
949 ("2.16.840.1.101.3.4.3.18", "ML-DSA-65", "ml-dsa-65"),
950 ("2.16.840.1.101.3.4.3.19", "ML-DSA-87", "ml-dsa-87"),
951 ("2.16.840.1.101.3.4.4.1", "ML-KEM-512", "ml-kem-512"),
952 ("2.16.840.1.101.3.4.4.2", "ML-KEM-768", "ml-kem-768"),
953 ("2.16.840.1.101.3.4.4.3", "ML-KEM-1024", "ml-kem-1024"),
954 ];
955
956 let oid_str = alg_oid
957 .iter()
958 .map(|c| c.to_string())
959 .collect::<Vec<_>>()
960 .join(".");
961
962 if let Some(&(_, display_name, level)) =
963 PQC_ALGORITHMS.iter().find(|(oid, _, _)| *oid == oid_str)
964 {
965 debug!(
966 algorithm = display_name,
967 key_bits = bit_count,
968 "CSR public key"
969 );
970 let allowed = if level.starts_with("ml-dsa") {
971 &profile.allowed_ml_dsa_levels
972 } else {
973 &profile.allowed_ml_kem_levels
974 };
975 if !allowed.is_empty() && !allowed.iter().any(|l| l == level) {
976 return Err(IssuanceError::AlgorithmNotAllowed {
977 algorithm: display_name.into(),
978 profile: profile.name.clone(),
979 });
980 }
981 } else if oid_str.starts_with("2.16.840.1.114027.80.5.2.") {
982 debug!(algorithm = %alg_name, oid = %oid_str, "CSR public key: composite ML-DSA");
984 if !profile.allow_composite_ml_dsa {
985 return Err(IssuanceError::AlgorithmNotAllowed {
986 algorithm: format!("composite ML-DSA ({oid_str})"),
987 profile: profile.name.clone(),
988 });
989 }
990 } else {
991 debug!(
992 algorithm = %alg_name,
993 key_bits = bit_count,
994 alg_oid = ?alg_oid,
995 "CSR public key: unknown algorithm (skipping size check)"
996 );
997 }
998 }
999 }
1000 Ok(())
1001}
1002
1003fn check_validity_period(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
1005 let max_days = cab_forum_max_validity_days();
1006
1007 if profile.max_validity_days > max_days {
1008 warn!(
1009 requested = profile.max_validity_days,
1010 max = max_days,
1011 "validity period exceeds CA/B Forum maximum"
1012 );
1013 return Err(IssuanceError::ValidityTooLong {
1014 requested_days: profile.max_validity_days,
1015 max_days,
1016 });
1017 }
1018
1019 Ok(())
1020}
1021
1022fn encode_certificate_policies(policy_oids: &[String]) -> Result<Vec<u8>, IssuanceError> {
1024 let mut builder = synta_certificate::CertificatePoliciesBuilder::new();
1025 for oid_str in policy_oids {
1026 let components: Vec<u32> = oid_str
1027 .split('.')
1028 .map(|s| s.parse())
1029 .collect::<Result<_, _>>()
1030 .map_err(|e| {
1031 IssuanceError::InvalidCsr(format!("invalid policy OID '{oid_str}': {e}"))
1032 })?;
1033 builder = builder.add_policy(&components);
1034 }
1035 builder.build().map_err(|e| {
1036 IssuanceError::SigningError(format!("Certificate Policies encoding failed: {e}"))
1037 })
1038}
1039
1040fn encode_aia_extension(ocsp_url: &str) -> Result<Vec<u8>, IssuanceError> {
1042 synta_certificate::AuthorityInformationAccessBuilder::new()
1043 .ocsp(ocsp_url)
1044 .build()
1045 .map_err(|e| IssuanceError::SigningError(format!("AIA encoding failed: {e}")))
1046}
1047
1048fn encode_cdp_extension(crl_url: &str) -> Result<Vec<u8>, IssuanceError> {
1050 synta_certificate::CRLDistributionPointsBuilder::new()
1051 .full_name_uri(crl_url)
1052 .build()
1053 .map_err(|e| IssuanceError::SigningError(format!("CDP encoding failed: {e}")))
1054}
1055
1056pub fn cab_forum_max_validity_days() -> u32 {
1068 use chrono::NaiveDate;
1069
1070 let today = Utc::now().date_naive();
1071 let p200 = NaiveDate::from_ymd_opt(2026, 3, 15).expect("valid date");
1072 let p100 = NaiveDate::from_ymd_opt(2027, 3, 15).expect("valid date");
1073 let p47 = NaiveDate::from_ymd_opt(2029, 3, 15).expect("valid date");
1074
1075 if today < p200 {
1076 398
1077 } else if today < p100 {
1078 200
1079 } else if today < p47 {
1080 100
1081 } else {
1082 47
1083 }
1084}
1085
1086struct HsmCertificateSigner<'a> {
1093 context: &'a Arc<kipuka_hsm::HsmContext>,
1094 key_label: &'a str,
1095 hash_algorithm: &'a str,
1096}
1097
1098#[derive(Debug)]
1100struct HsmSignerError(String);
1101
1102impl std::fmt::Display for HsmSignerError {
1103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104 write!(f, "{}", self.0)
1105 }
1106}
1107
1108impl std::error::Error for HsmSignerError {}
1109
1110impl<'a> synta_certificate::CertificateSigner for HsmCertificateSigner<'a> {
1111 type Error = HsmSignerError;
1112
1113 fn signature_algorithm_der(&self) -> Result<Vec<u8>, Self::Error> {
1114 match self.hash_algorithm {
1124 "sha256" => {
1125 Ok(vec![
1127 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
1130 0x0b, 0x05, 0x00, ])
1133 }
1134 "sha384" => {
1135 Ok(vec![
1137 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c,
1138 0x05, 0x00,
1139 ])
1140 }
1141 "sha512" => {
1142 Ok(vec![
1144 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d,
1145 0x05, 0x00,
1146 ])
1147 }
1148 other => Err(HsmSignerError(format!(
1149 "unsupported hash algorithm for HSM RSA signing: {other}"
1150 ))),
1151 }
1152 }
1153
1154 fn sign_tbs(&self, tbs_der: &[u8]) -> Result<Vec<u8>, Self::Error> {
1155 self.context
1158 .sign_data(self.key_label, tbs_der, self.hash_algorithm)
1159 .map_err(|e| HsmSignerError(format!("PKCS#11 sign failed: {e}")))
1160 }
1161}
1162
1163fn check_required_extensions(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
1165 if !profile.include_aki {
1166 return Err(IssuanceError::MissingExtension(
1167 "Authority Key Identifier (required by CA/B Forum)".into(),
1168 ));
1169 }
1170 if !profile.include_ski {
1171 return Err(IssuanceError::MissingExtension(
1172 "Subject Key Identifier (required by CA/B Forum)".into(),
1173 ));
1174 }
1175 if profile.key_usage.is_empty() {
1176 return Err(IssuanceError::MissingExtension(
1177 "Key Usage (required by CA/B Forum)".into(),
1178 ));
1179 }
1180
1181 Ok(())
1182}
1183
1184pub(crate) fn requested_extensions(
1186 csr_der: &[u8],
1187) -> Result<Vec<(synta::ObjectIdentifier, bool, Vec<u8>)>, IssuanceError> {
1188 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
1189 .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1190 let mut result = Vec::new();
1191 if let Some(attrs) = &csr.certification_request_info.attributes {
1192 for attr in attrs.elements() {
1193 if attr.attr_type.components() != synta_certificate::oids::PKCS9_EXTENSION_REQUEST {
1194 continue;
1195 }
1196 for value in attr.attr_values.elements() {
1197 let extensions: Vec<synta_certificate::Extension<'_>> =
1198 synta::Decoder::new(value.as_bytes(), synta::Encoding::Der)
1199 .decode()
1200 .map_err(|e| {
1201 IssuanceError::InvalidCsr(format!("requested extensions: {e}"))
1202 })?;
1203 for ext in extensions {
1204 if result.iter().any(|(oid, _, _)| oid == &ext.extn_id) {
1205 return Err(IssuanceError::InvalidCsr(
1206 "duplicate requested extension".into(),
1207 ));
1208 }
1209 result.push((
1210 ext.extn_id,
1211 ext.critical.as_ref().is_some_and(|v| v.0),
1212 ext.extn_value.as_bytes().to_vec(),
1213 ));
1214 }
1215 }
1216 }
1217 }
1218 Ok(result)
1219}
1220
1221pub(crate) fn issue_crmf_certificate(
1224 request: &synta_certificate::crmf_types::CertReqMsg<'_>,
1225 profile: &EnrollmentProfile,
1226 ca_cert_der: &[u8],
1227 signing_key: CaSigningKey<'_>,
1228 hash_algorithm: &str,
1229 ocsp_url: Option<&str>,
1230 crl_url: Option<&str>,
1231) -> Result<IssuanceResult, IssuanceError> {
1232 use synta_certificate::{SignatureVerifier, crmf_types::ProofOfPossession};
1233 let invalid = |e| IssuanceError::InvalidCsr(format!("CRMF encoding: {e}"));
1234 let template = &request.cert_req.cert_template;
1235 let subject = template
1236 .subject
1237 .as_ref()
1238 .ok_or_else(|| IssuanceError::InvalidCsr("CRMF subject required".into()))?
1239 .to_der()
1240 .map_err(invalid)?;
1241 let spki = template
1242 .public_key
1243 .as_ref()
1244 .ok_or_else(|| IssuanceError::InvalidCsr("CRMF public key required".into()))?
1245 .to_der()
1246 .map_err(invalid)?;
1247 let Some(ProofOfPossession::Signature(pop)) = &request.popo else {
1248 return Err(IssuanceError::InvalidCsr(
1249 "supported CRMF signature proof of possession required".into(),
1250 ));
1251 };
1252 if pop.poposk_input.is_some() {
1255 return Err(IssuanceError::InvalidCsr(
1256 "unexpected poposkInput for complete CertTemplate".into(),
1257 ));
1258 }
1259 let tbs = request.cert_req.to_der().map_err(invalid)?;
1260 let algorithm = pop.algorithm_identifier.to_der().map_err(invalid)?;
1261 synta_certificate::default_signature_verifier()
1262 .verify_certificate_signature(&tbs, &algorithm, pop.signature.as_bytes(), &spki)
1263 .map_err(|e| IssuanceError::InvalidCsr(format!("CRMF proof of possession failed: {e}")))?;
1264 let mut builder = synta_certificate::CsrBuilder::new()
1265 .subject_name(&subject)
1266 .public_key_der(&spki);
1267 if let Some(extensions) = &template.extensions {
1268 for ext in extensions {
1269 builder = builder.add_extension(
1270 ext.extn_id.clone(),
1271 ext.critical.as_ref().is_some_and(|v| v.0),
1272 ext.extn_value.as_bytes(),
1273 );
1274 }
1275 }
1276 let cri = builder
1278 .build_cri(&algorithm)
1279 .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1280 let csr = synta_certificate::CsrBuilder::assemble(&cri, &algorithm, pop.signature.as_bytes())
1281 .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1282 issue_verified_request(
1283 &csr,
1284 profile,
1285 ca_cert_der,
1286 signing_key,
1287 hash_algorithm,
1288 ocsp_url,
1289 crl_url,
1290 )
1291}
1292
1293pub(crate) fn sign_message(
1295 key: CaSigningKey<'_>,
1296 hash: &str,
1297 message: &[u8],
1298) -> Result<(Vec<u8>, Vec<u8>), crate::error::KipukaError> {
1299 use synta_certificate::{CertificateSigner, PrivateKey};
1300 let err = |e: String| crate::error::KipukaError::Ca(format!("message signing failed: {e}"));
1301 match key {
1302 CaSigningKey::Pem(pem) => {
1303 let key = synta_certificate::BackendPrivateKey::from_pem(pem, None)
1304 .map_err(|e| err(e.to_string()))?;
1305 let signer = key.as_signer(hash);
1306 Ok((
1307 signer
1308 .signature_algorithm_der_erased()
1309 .map_err(|e| err(e.to_string()))?,
1310 signer
1311 .sign_tbs_erased(message)
1312 .map_err(|e| err(e.to_string()))?,
1313 ))
1314 }
1315 CaSigningKey::Hsm { context, key_label } => {
1316 let signer = HsmCertificateSigner {
1317 context,
1318 key_label,
1319 hash_algorithm: hash,
1320 };
1321 Ok((
1322 signer
1323 .signature_algorithm_der()
1324 .map_err(|e| err(e.to_string()))?,
1325 signer.sign_tbs(message).map_err(|e| err(e.to_string()))?,
1326 ))
1327 }
1328 }
1329}
1330
1331pub(crate) async fn persist_certificate(
1333 state: &crate::state::AppState,
1334 ca_id: &str,
1335 profile: &str,
1336 result: &IssuanceResult,
1337) -> Result<(), crate::error::KipukaError> {
1338 persist_certificate_on(&state.db, ca_id, profile, result).await?;
1339 crate::audit::record_checked(
1340 &state.db,
1341 &state.audit,
1342 crate::audit::AuditEvent::new(crate::audit::AuditEventType::CertIssue)
1343 .with_detail(format!("ca_id={ca_id}, serial={}", result.serial_number)),
1344 )
1345 .await?;
1346 Ok(())
1347}
1348
1349pub(crate) async fn persist_certificate_on<'e, E: sqlx::Executor<'e, Database = sqlx::Any>>(
1351 executor: E,
1352 ca_id: &str,
1353 profile: &str,
1354 result: &IssuanceResult,
1355) -> Result<(), crate::error::KipukaError> {
1356 let cert = synta_certificate::Certificate::from_der(&result.certificate_der)
1357 .map_err(|e| crate::error::KipukaError::Ca(e.to_string()))?;
1358 let issuer = synta_certificate::format_dn(cert.tbs_certificate.issuer.as_bytes());
1359 sqlx::query(crate::db::pg_sql("INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')"))
1360 .bind(&result.serial_number).bind(&result.subject_dn).bind(issuer)
1361 .bind(result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string())
1362 .bind(result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string())
1363 .bind(&result.certificate_der).bind(ca_id).bind(profile).execute(executor).await
1364 .map_err(|e| crate::error::KipukaError::Db(format!("certificate persistence failed: {e}")))?;
1365 Ok(())
1366}
1367
1368fn verify_csr_pop(csr_der: &[u8]) -> Result<(), IssuanceError> {
1383 use synta_certificate::SignatureVerifier;
1384
1385 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
1386 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
1387
1388 let spki_alg_oid = &csr
1392 .certification_request_info
1393 .subject_pkinfo
1394 .algorithm
1395 .algorithm;
1396 if let Some(name) = synta_certificate::identify_public_key_algorithm(spki_alg_oid)
1397 && matches!(
1398 name,
1399 synta_certificate::names::ML_KEM_512
1400 | synta_certificate::names::ML_KEM_768
1401 | synta_certificate::names::ML_KEM_1024
1402 )
1403 {
1404 warn!(
1405 algorithm = %name,
1406 "rejecting ML-KEM CSR: separate ML-DSA proof-of-possession not yet supported"
1407 );
1408 return Err(IssuanceError::InvalidCsr(format!(
1409 "{name} is a key-encapsulation key and cannot self-sign; \
1410 ML-KEM proof-of-possession (a separate ML-DSA signature) is not yet supported"
1411 )));
1412 }
1413
1414 let cri_der = csr
1416 .certification_request_info
1417 .to_der()
1418 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR CRI encode failed: {e}")))?;
1419
1420 let sig_alg_der = csr
1422 .signature_algorithm
1423 .to_der()
1424 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR sig alg encode failed: {e}")))?;
1425
1426 let signature_bits = csr.signature.as_bytes();
1428
1429 let spki_der = csr
1431 .certification_request_info
1432 .subject_pkinfo
1433 .to_der()
1434 .map_err(|e| IssuanceError::InvalidCsr(format!("CSR SPKI encode failed: {e}")))?;
1435
1436 let verifier = synta_certificate::default_signature_verifier();
1437 verifier
1438 .verify_certificate_signature(&cri_der, &sig_alg_der, signature_bits, &spki_der)
1439 .map_err(|e| {
1440 warn!(error = %e, "CSR proof-of-possession verification failed");
1441 IssuanceError::InvalidCsr(format!(
1442 "CSR self-signature (proof-of-possession) verification failed: {e}"
1443 ))
1444 })?;
1445
1446 debug!("CSR proof-of-possession verified");
1447 Ok(())
1448}
1449
1450#[cfg(test)]
1451mod issuance_error_classification_tests {
1452 use super::IssuanceError;
1458 use crate::error::KipukaError;
1459
1460 #[test]
1461 fn csr_content_faults_are_client_errors() {
1462 let client = [
1463 IssuanceError::InvalidCsr("bad self-signature".into()),
1464 IssuanceError::KeyTooSmall {
1465 algorithm: "RSA".into(),
1466 bits: 1024,
1467 min_bits: 2048,
1468 },
1469 IssuanceError::AlgorithmNotAllowed {
1470 algorithm: "MD5".into(),
1471 profile: "default".into(),
1472 },
1473 IssuanceError::ProhibitedDnAttribute {
1474 oid: "2.5.4.5".into(),
1475 name: "serialNumber".into(),
1476 },
1477 ];
1478 for e in client {
1479 assert!(e.is_client_error(), "{e} must classify as a client error");
1480 assert!(
1481 matches!(KipukaError::from(e), KipukaError::BadRequest(_)),
1482 "a client CSR fault must map to BadRequest (400)"
1483 );
1484 }
1485 }
1486
1487 #[test]
1488 fn server_and_profile_faults_are_server_errors() {
1489 let server = [
1492 IssuanceError::SigningError("HSM offline".into()),
1493 IssuanceError::StorageError("db write failed".into()),
1494 IssuanceError::MissingExtension("keyUsage".into()),
1495 IssuanceError::ValidityTooLong {
1496 requested_days: 900,
1497 max_days: 825,
1498 },
1499 IssuanceError::UnknownProfile("ghost".into()),
1500 ];
1501 for e in server {
1502 assert!(!e.is_client_error(), "{e} must classify as a server error");
1503 assert!(
1504 matches!(KipukaError::from(e), KipukaError::Ca(_)),
1505 "a server/profile fault must map to Ca (500)"
1506 );
1507 }
1508 }
1509}