Skip to main content

kipuka/auth/
cms_auth.rs

1//! CMS message-level authentication for EST (RFC 8295).
2//!
3//! When TLS termination happens at a proxy, EST can still provide
4//! message-level security using CMS (Cryptographic Message Syntax):
5//!
6//! - **Request authentication**: CMS SignedData wraps the PKCS#10 CSR.
7//!   The signer certificate is verified against the EST truststore.
8//!
9//! - **Response confidentiality**: CMS EnvelopedData encrypts the issued
10//!   certificate to the client's public key extracted from the CSR or
11//!   the CMS SignedData signer certificate.
12//!
13//! RFC 8295 §3: The EST server MUST verify the CMS SignedData signature
14//! and extract the signer's certificate for identity verification.
15
16use sha2::Digest;
17use synta::{Decoder, Encoding, Tag, TagClass};
18use synta_certificate::oids::{self, CMS_SIGNED_DATA};
19use synta_certificate::{
20    BackendPublicKey, CertByteRanges, Certificate, KeyWrapAlgorithm, cert_byte_ranges,
21    default_signature_verifier, name::format_dn,
22};
23use tracing::warn;
24
25use crate::auth::{AuthMethod, AuthResult};
26use crate::error::KipukaError;
27
28/// Result of verifying a CMS SignedData message (RFC 8295 §3.1).
29///
30/// After successful verification, the signer's certificate and the
31/// unwrapped payload (typically a PKCS#10 CSR) are available for
32/// further processing by the EST handler.
33#[derive(Debug, Clone)]
34pub struct CmsVerificationResult {
35    /// DER-encoded signer certificate extracted from the SignedData.
36    ///
37    /// RFC 8295 §3.1: The signer's certificate is included in the
38    /// `certificates` field of the SignedData and MUST chain to a
39    /// trust anchor in the EST truststore.
40    pub signer_cert_der: Vec<u8>,
41
42    /// Subject DN of the signer certificate as a string.
43    ///
44    /// Used for identity extraction and audit logging.
45    pub signer_subject_dn: String,
46
47    /// The unwrapped payload extracted from the SignedData `encapContentInfo`.
48    ///
49    /// For EST operations this is typically a DER-encoded PKCS#10 CSR.
50    pub payload: Vec<u8>,
51
52    /// Signature algorithm OID or name used to sign the CMS message.
53    ///
54    /// Verified against the server's allowed algorithm list to reject
55    /// weak algorithms (e.g., MD5, SHA-1).
56    pub signature_algorithm: String,
57}
58
59/// Content encryption algorithms supported for CMS EnvelopedData.
60///
61/// RFC 8295 §3.2: The EST server encrypts the response to the client's
62/// public key.  Only AEAD or CBC modes with NIST-approved ciphers are
63/// permitted.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum SupportedContentEncryption {
66    /// AES-256-GCM (OID 2.16.840.1.101.3.4.1.46).
67    Aes256Gcm,
68    /// AES-128-GCM (OID 2.16.840.1.101.3.4.1.6).
69    Aes128Gcm,
70    /// AES-256-CBC (OID 2.16.840.1.101.3.4.1.42).
71    Aes256Cbc,
72    /// AES-128-CBC (OID 2.16.840.1.101.3.4.1.2).
73    Aes128Cbc,
74}
75
76/// Validate a content encryption algorithm string and map it to a
77/// supported variant.
78///
79/// Accepts OID strings or short names (e.g., `"aes-256-gcm"`,
80/// `"2.16.840.1.101.3.4.1.46"`).
81///
82/// # Errors
83///
84/// Returns `KipukaError::BadRequest` if the algorithm is not recognised
85/// or not permitted by policy.
86pub fn validate_content_encryption(alg: &str) -> Result<SupportedContentEncryption, KipukaError> {
87    match alg.to_ascii_lowercase().as_str() {
88        "aes-256-gcm" | "aes256gcm" | "2.16.840.1.101.3.4.1.46" => {
89            Ok(SupportedContentEncryption::Aes256Gcm)
90        }
91        "aes-128-gcm" | "aes128gcm" | "2.16.840.1.101.3.4.1.6" => {
92            Ok(SupportedContentEncryption::Aes128Gcm)
93        }
94        "aes-256-cbc" | "aes256cbc" | "2.16.840.1.101.3.4.1.42" => {
95            Ok(SupportedContentEncryption::Aes256Cbc)
96        }
97        "aes-128-cbc" | "aes128cbc" | "2.16.840.1.101.3.4.1.2" => {
98            Ok(SupportedContentEncryption::Aes128Cbc)
99        }
100        _ => Err(KipukaError::BadRequest(format!(
101            "unsupported content encryption algorithm: {alg}"
102        ))),
103    }
104}
105
106/// Verify a CMS SignedData message and extract the payload.
107///
108/// RFC 8295 §3.1: The EST server performs the following steps:
109///
110/// 1. Parse the outer ContentInfo (DER) and verify `contentType` is
111///    `id-signedData` (OID 1.2.840.113549.1.7.2).
112/// 2. Extract the `SignerInfo` — exactly one signer is expected for EST.
113/// 3. Locate the signer's certificate in the `certificates` field.
114/// 4. Verify the signature using the signer's public key and the
115///    `digestAlgorithm` + `signatureAlgorithm` from `SignerInfo`.
116/// 5. Validate the signer's certificate chain against `truststore`:
117///    - Build a chain from the signer cert to a trust anchor.
118///    - Check validity periods (notBefore/notAfter).
119///    - Check revocation status (CRL/OCSP) if configured.
120/// 6. Extract the `eContent` from `encapContentInfo` — the unwrapped
121///    payload (CSR).
122///
123/// # Arguments
124///
125/// * `signed_data_der` — DER-encoded CMS ContentInfo containing SignedData.
126/// * `truststore` — DER-encoded trust anchor certificates to verify
127///   the signer's certificate chain against.
128///
129/// # Errors
130///
131/// - `KipukaError::BadRequest` — malformed CMS, missing signer, empty payload.
132/// - `KipukaError::Auth` — signature verification failure, untrusted signer.
133/// - `KipukaError::Internal` — crypto operations not yet implemented.
134pub fn verify_cms_signed_data(
135    signed_data_der: &[u8],
136    truststore: &[Vec<u8>],
137) -> Result<CmsVerificationResult, KipukaError> {
138    // Input validation.
139    if signed_data_der.is_empty() {
140        return Err(KipukaError::BadRequest("CMS SignedData is empty".into()));
141    }
142
143    // A minimal CMS ContentInfo with SignedData is at least ~100 bytes:
144    // ContentInfo SEQUENCE header + OID + SignedData structure.
145    if signed_data_der.len() < 100 {
146        return Err(KipukaError::BadRequest(
147            "CMS SignedData is too short to be valid".into(),
148        ));
149    }
150
151    if truststore.is_empty() {
152        return Err(KipukaError::Auth(
153            "CMS truststore is empty — cannot verify signer certificate".into(),
154        ));
155    }
156
157    // 1. Parse the outer ContentInfo SEQUENCE and verify contentType is id-signedData.
158    let mut outer_dec = Decoder::new(signed_data_der, Encoding::Ber);
159    let seq_tag = Tag::universal_constructed(16); // SEQUENCE
160    let mut ci_dec = outer_dec
161        .enter_constructed(seq_tag)
162        .map_err(|e| KipukaError::BadRequest(format!("CMS ContentInfo parse error: {e:?}")))?;
163
164    let content_type: synta::ObjectIdentifier = ci_dec
165        .decode()
166        .map_err(|e| KipukaError::BadRequest(format!("CMS contentType OID parse error: {e:?}")))?;
167
168    if content_type.components() != CMS_SIGNED_DATA {
169        return Err(KipukaError::BadRequest(
170            "CMS ContentInfo contentType is not id-signedData".into(),
171        ));
172    }
173
174    // Enter the [0] EXPLICIT content wrapper.
175    let ctx0_tag = Tag::new(TagClass::ContextSpecific, true, 0);
176    let mut content_dec = ci_dec.enter_constructed(ctx0_tag).map_err(|e| {
177        KipukaError::BadRequest(format!("CMS [0] EXPLICIT wrapper parse error: {e:?}"))
178    })?;
179
180    // 2. Enter the SignedData SEQUENCE.
181    let mut sd_dec = content_dec.enter_constructed(seq_tag).map_err(|e| {
182        KipukaError::BadRequest(format!("CMS SignedData SEQUENCE parse error: {e:?}"))
183    })?;
184
185    // version INTEGER
186    let _version: synta::RawDer = sd_dec.decode().map_err(|e| {
187        KipukaError::BadRequest(format!("CMS SignedData version parse error: {e:?}"))
188    })?;
189
190    // digestAlgorithms SET OF AlgorithmIdentifier — skip
191    let _digest_algs: synta::RawDer = sd_dec
192        .decode()
193        .map_err(|e| KipukaError::BadRequest(format!("CMS digestAlgorithms parse error: {e:?}")))?;
194
195    // 3. encapContentInfo SEQUENCE — extract eContent payload.
196    let encap_ci_tag = seq_tag;
197    let mut encap_dec = sd_dec
198        .enter_constructed(encap_ci_tag)
199        .map_err(|e| KipukaError::BadRequest(format!("CMS encapContentInfo parse error: {e:?}")))?;
200
201    // eContentType OID
202    let _econtent_type: synta::ObjectIdentifier = encap_dec
203        .decode()
204        .map_err(|e| KipukaError::BadRequest(format!("CMS eContentType parse error: {e:?}")))?;
205
206    // eContent [0] EXPLICIT OCTET STRING (optional)
207    let payload = if !encap_dec.is_empty() {
208        let ctx0_econtent = Tag::new(TagClass::ContextSpecific, true, 0);
209        let mut econtent_wrapper = encap_dec.enter_constructed(ctx0_econtent).map_err(|e| {
210            KipukaError::BadRequest(format!("CMS eContent [0] wrapper parse error: {e:?}"))
211        })?;
212
213        let octet: synta::RawDer = econtent_wrapper.decode().map_err(|e| {
214            KipukaError::BadRequest(format!("CMS eContent OCTET STRING parse error: {e:?}"))
215        })?;
216
217        // The RawDer captures the full TLV; extract just the OCTET STRING value.
218        let octet_bytes = octet.as_bytes();
219        let mut val_dec = Decoder::new(octet_bytes, Encoding::Ber);
220        val_dec
221            .read_tag()
222            .map_err(|e| KipukaError::BadRequest(format!("CMS eContent tag error: {e:?}")))?;
223        let val_len = val_dec
224            .read_length()
225            .map_err(|e| KipukaError::BadRequest(format!("CMS eContent length error: {e:?}")))?
226            .definite()
227            .map_err(|e| {
228                KipukaError::BadRequest(format!("CMS eContent indefinite length: {e:?}"))
229            })?;
230        let val_bytes = val_dec
231            .read_bytes(val_len)
232            .map_err(|e| KipukaError::BadRequest(format!("CMS eContent value error: {e:?}")))?;
233        val_bytes.to_vec()
234    } else {
235        return Err(KipukaError::BadRequest(
236            "CMS SignedData has no encapsulated content (eContent is absent)".into(),
237        ));
238    };
239
240    if payload.is_empty() {
241        return Err(KipukaError::BadRequest(
242            "CMS SignedData eContent is empty".into(),
243        ));
244    }
245
246    // 4. Extract certificates from the optional [0] IMPLICIT CertificateSet.
247    //    Also capture the signerInfos SET at the end.
248    let mut signer_certs: Vec<Vec<u8>> = Vec::new();
249    let mut signer_infos_raw: Option<Vec<u8>> = None;
250
251    while !sd_dec.is_empty() {
252        let next_tag = sd_dec.peek_tag().map_err(|e| {
253            KipukaError::BadRequest(format!("CMS SignedData field peek error: {e:?}"))
254        })?;
255
256        if next_tag.class() == TagClass::ContextSpecific && next_tag.number() == 0 {
257            // certificates [0] IMPLICIT — extract individual certs
258            let ctx0_certs = Tag::new(TagClass::ContextSpecific, true, 0);
259            let mut cert_set = sd_dec.enter_constructed(ctx0_certs).map_err(|e| {
260                KipukaError::BadRequest(format!("CMS certificates field parse error: {e:?}"))
261            })?;
262
263            while !cert_set.is_empty() {
264                let cert_tag = cert_set.peek_tag().map_err(|e| {
265                    KipukaError::BadRequest(format!("CMS cert entry peek error: {e:?}"))
266                })?;
267
268                if cert_tag.class() == TagClass::Universal && cert_tag.number() == 16 {
269                    let raw: synta::RawDer = cert_set.decode().map_err(|e| {
270                        KipukaError::BadRequest(format!("CMS cert parse error: {e:?}"))
271                    })?;
272                    signer_certs.push(raw.as_bytes().to_vec());
273                } else {
274                    // Skip non-certificate alternatives (attribute certs, etc.)
275                    let _: synta::RawDer = cert_set.decode().map_err(|e| {
276                        KipukaError::BadRequest(format!("CMS cert alt skip error: {e:?}"))
277                    })?;
278                }
279            }
280        } else if next_tag.class() == TagClass::ContextSpecific && next_tag.number() == 1 {
281            // crls [1] IMPLICIT — skip
282            let _: synta::RawDer = sd_dec
283                .decode()
284                .map_err(|e| KipukaError::BadRequest(format!("CMS crls skip error: {e:?}")))?;
285        } else if next_tag.class() == TagClass::Universal && next_tag.number() == 17 {
286            // signerInfos SET
287            let raw: synta::RawDer = sd_dec.decode().map_err(|e| {
288                KipukaError::BadRequest(format!("CMS signerInfos parse error: {e:?}"))
289            })?;
290            signer_infos_raw = Some(raw.as_bytes().to_vec());
291        } else {
292            // Unknown field — skip
293            let _: synta::RawDer = sd_dec.decode().map_err(|e| {
294                KipukaError::BadRequest(format!("CMS unknown field skip error: {e:?}"))
295            })?;
296        }
297    }
298
299    let si_bytes = signer_infos_raw
300        .ok_or_else(|| KipukaError::BadRequest("CMS SignedData has no signerInfos SET".into()))?;
301
302    if signer_certs.is_empty() {
303        return Err(KipukaError::BadRequest(
304            "CMS SignedData has no certificates — signer cert is required".into(),
305        ));
306    }
307
308    // 5. Parse the first SignerInfo from the SET using the synta CMS types.
309    //    EST requires exactly one signer (RFC 8295 §3.1).
310    let set_tag = Tag::universal_constructed(17); // SET
311    let mut si_set_dec = Decoder::new(&si_bytes, Encoding::Ber);
312    let mut si_inner = si_set_dec
313        .enter_constructed(set_tag)
314        .map_err(|e| KipukaError::BadRequest(format!("CMS signerInfos SET enter error: {e:?}")))?;
315
316    if si_inner.is_empty() {
317        return Err(KipukaError::BadRequest(
318            "CMS SignedData signerInfos is empty — at least one signer required".into(),
319        ));
320    }
321
322    // Capture the first SignerInfo SEQUENCE as raw bytes, then parse with
323    // the synta cms_2009_types::SignerInfo struct for structured access.
324    let si_raw: synta::RawDer = si_inner
325        .decode()
326        .map_err(|e| KipukaError::BadRequest(format!("CMS SignerInfo parse error: {e:?}")))?;
327    let si_raw_bytes = si_raw.as_bytes();
328
329    let signer_info = synta_certificate::cms_2009_types::SignerInfo::from_der(si_raw_bytes)
330        .map_err(|e| {
331            KipukaError::BadRequest(format!("CMS SignerInfo structured parse error: {e:?}"))
332        })?;
333
334    // Extract the signatureAlgorithm OID string for the result.
335    let sig_alg_oid_str = {
336        let mut alg_dec = Decoder::new(signer_info.signature_algorithm.as_bytes(), Encoding::Der);
337        let mut alg_seq = alg_dec
338            .enter_constructed(seq_tag)
339            .map_err(|e| KipukaError::BadRequest(format!("CMS sigAlg SEQUENCE error: {e:?}")))?;
340        let oid: synta::ObjectIdentifier = alg_seq
341            .decode()
342            .map_err(|e| KipukaError::BadRequest(format!("CMS sigAlg OID error: {e:?}")))?;
343        oid.to_string()
344    };
345
346    // Extract the signature value bytes from the SignerInfo.
347    let sig_bytes = signer_info.signature.as_bytes().to_vec();
348
349    // 6. Match the signer certificate using the sid (SignerIdentifier).
350    //    RFC 5652 §5.3: The sid identifies the signer's certificate by
351    //    either IssuerAndSerialNumber or SubjectKeyIdentifier.
352    let sid_raw = signer_info.sid.as_bytes();
353    let signer_cert_der = match_signer_cert_by_sid(sid_raw, &signer_certs)?;
354
355    // Extract the signer cert byte ranges for signature verification.
356    let signer_ranges: CertByteRanges = cert_byte_ranges(signer_cert_der).ok_or_else(|| {
357        KipukaError::BadRequest("failed to parse signer certificate structure".into())
358    })?;
359
360    let signer_spki_der = &signer_cert_der[signer_ranges.subject_public_key_info.clone()];
361
362    // Extract the subject DN from the signer certificate using synta's Decoder.
363    let signer_subject_dn = {
364        let cert: synta_certificate::Certificate<'_> = Decoder::new(signer_cert_der, Encoding::Der)
365            .decode()
366            .map_err(|e| KipukaError::BadRequest(format!("signer cert decode error: {e:?}")))?;
367        let subject_raw = cert.tbs_certificate.subject.as_bytes();
368        format_dn(subject_raw)
369    };
370
371    super::certificate::valid_now(signer_cert_der).map_err(KipukaError::Auth)?;
372
373    // 7. Verify the signer's certificate chains to a trust anchor.
374    //    We do a simple direct-issuer check: the signer cert must be signed
375    //    by one of the truststore certificates.
376    //    Use Certificate::from_der() to access signature_value directly
377    //    instead of the manual extract_cert_signature_bits helper.
378    let signer_cert_parsed = Certificate::from_der(signer_cert_der).map_err(|e| {
379        KipukaError::BadRequest(format!("failed to parse signer certificate: {e:?}"))
380    })?;
381    let cert_sig_bits = signer_cert_parsed.signature_value.as_bytes();
382
383    let verifier = default_signature_verifier();
384    let mut signer_trusted = false;
385    for ta_der in truststore {
386        let ta_ranges = match cert_byte_ranges(ta_der) {
387            Some(r) => r,
388            None => continue,
389        };
390        let ta_spki = &ta_der[ta_ranges.subject_public_key_info.clone()];
391
392        // Try to verify signer cert's signature against this trust anchor's SPKI.
393        if verifier
394            .verify_certificate_signature_erased(
395                &signer_cert_der[signer_ranges.tbs.clone()],
396                &signer_cert_der[signer_ranges.signature_algorithm.clone()],
397                cert_sig_bits,
398                ta_spki,
399            )
400            .is_ok()
401        {
402            signer_trusted = true;
403            break;
404        }
405
406        // Also accept self-signed: trust anchor == signer cert.
407        if ta_der == signer_cert_der {
408            signer_trusted = true;
409            break;
410        }
411    }
412
413    if !signer_trusted {
414        return Err(KipukaError::Auth(
415            "CMS signer certificate does not chain to a trust anchor".into(),
416        ));
417    }
418
419    // 8. Verify the CMS signature itself.
420    //    RFC 5652 §5.4: When signedAttrs are present, the signature is computed
421    //    over the DER-encoded signedAttrs (re-tagged from IMPLICIT [0] 0xa0 to
422    //    SET OF 0x31). The message-digest attribute within signedAttrs must match
423    //    a digest of the eContent payload.
424    //    When signedAttrs are absent, the signature is over the payload directly.
425    let verification_data = if let Some(ref signed_attrs_raw) = signer_info.signed_attrs {
426        // signedAttrs are present — verify message-digest attribute matches
427        // a hash of eContent, then verify signature over the re-tagged signedAttrs.
428        let signed_attrs_bytes = signed_attrs_raw.as_bytes();
429
430        // Verify message-digest attribute: extract the digest algorithm OID from
431        // the SignerInfo and hash the eContent payload.
432        let digest_alg_oid = {
433            let mut da_dec = Decoder::new(signer_info.digest_algorithm.as_bytes(), Encoding::Der);
434            let mut da_seq = da_dec.enter_constructed(seq_tag).map_err(|e| {
435                KipukaError::BadRequest(format!("CMS digestAlg SEQUENCE error: {e:?}"))
436            })?;
437            let oid: synta::ObjectIdentifier = da_seq
438                .decode()
439                .map_err(|e| KipukaError::BadRequest(format!("CMS digestAlg OID error: {e:?}")))?;
440            oid.components().to_vec()
441        };
442
443        let payload_hash = compute_digest(&digest_alg_oid, &payload)?;
444        verify_message_digest_attribute(signed_attrs_bytes, &payload_hash)?;
445
446        // Synta exposes the CONTENT of IMPLICIT [0], without its tag/length.
447        // RFC 5652 section 5.4 signs the complete DER SET OF encoding.
448        let mut encoder = synta::Encoder::new(Encoding::Der);
449        encoder
450            .start_constructed_no_guard(Tag::universal_constructed(17))
451            .map_err(|e| KipukaError::BadRequest(format!("CMS signedAttrs SET error: {e}")))?;
452        encoder
453            .encode(&synta::RawDer(signed_attrs_bytes))
454            .map_err(|e| KipukaError::BadRequest(format!("CMS signedAttrs encoding error: {e}")))?;
455        encoder
456            .end_constructed()
457            .map_err(|e| KipukaError::BadRequest(format!("CMS signedAttrs encoding error: {e}")))?;
458        encoder
459            .finish()
460            .map_err(|e| KipukaError::BadRequest(format!("CMS signedAttrs encoding error: {e}")))?
461    } else {
462        // No signedAttrs — signature is over the payload directly.
463        payload.clone()
464    };
465
466    // CMS permits rsaEncryption in signatureAlgorithm with the digest supplied
467    // separately in digestAlgorithm (RFC 3370). The verifier takes a combined
468    // signature algorithm, so bind the two explicitly without changing padding.
469    let verification_algorithm = if sig_alg_oid_str == "1.2.840.113549.1.1.1" {
470        let mut decoder = Decoder::new(signer_info.digest_algorithm.as_bytes(), Encoding::Der);
471        let mut algorithm = decoder
472            .enter_constructed(seq_tag)
473            .map_err(|e| KipukaError::BadRequest(format!("CMS digest algorithm: {e}")))?;
474        let oid: synta::ObjectIdentifier = algorithm
475            .decode()
476            .map_err(|e| KipukaError::BadRequest(format!("CMS digest OID: {e}")))?;
477        let hash = match oid.components() {
478            [2, 16, 840, 1, 101, 3, 4, 2, 1] => "sha256",
479            [2, 16, 840, 1, 101, 3, 4, 2, 2] => "sha384",
480            [2, 16, 840, 1, 101, 3, 4, 2, 3] => "sha512",
481            _ => return Err(KipukaError::Auth("unsupported CMS RSA digest".into())),
482        };
483        let rsa = synta::ObjectIdentifier::new(&[1, 2, 840, 113549, 1, 1, 1])
484            .map_err(|e| KipukaError::Internal(e.to_string()))?;
485        synta_certificate::signing_algorithm_der(&rsa, hash)
486            .ok_or_else(|| KipukaError::Auth("unsupported CMS RSA signature algorithm".into()))?
487    } else {
488        signer_info.signature_algorithm.as_bytes().to_vec()
489    };
490    let pub_key = BackendPublicKey::from_spki_der(signer_spki_der.to_vec());
491    pub_key
492        .verify_signature(&verification_data, &verification_algorithm, &sig_bytes)
493        .map_err(|e| {
494            KipukaError::Auth(format!("CMS SignedData signature verification failed: {e}"))
495        })?;
496
497    Ok(CmsVerificationResult {
498        signer_cert_der: signer_cert_der.clone(),
499        signer_subject_dn,
500        payload,
501        signature_algorithm: sig_alg_oid_str,
502    })
503}
504
505/// Encrypt a response as CMS AuthEnvelopedData (AES-GCM) or EnvelopedData (AES-CBC).
506///
507/// RFC 8295 §3.2: The EST server encrypts the response (issued
508/// certificate) to the client's public key so that only the client
509/// can decrypt it, even if the transport layer is plain HTTP.
510///
511/// The construction follows RFC 5652 §6 (EnvelopedData):
512///
513/// 1. Generate a random content-encryption key (CEK) for the selected
514///    algorithm (`content_encryption_alg`).
515/// 2. Encrypt `payload` with the CEK to produce the `encryptedContent`.
516/// 3. Encrypt the CEK to the recipient's public key (from
517///    `recipient_cert_der`) using `KeyTransRecipientInfo` (ktri).
518/// 4. Assemble the EnvelopedData:
519///    - `version`: 0 (ktri with issuerAndSerialNumber)
520///    - `recipientInfos`: one KeyTransRecipientInfo
521///    - `encryptedContentInfo`: the encrypted payload
522/// 5. Wrap in ContentInfo with `contentType` = `id-envelopedData`
523///    (OID 1.2.840.113549.1.7.3).
524/// 6. Return the DER-encoded ContentInfo.
525///
526/// # Arguments
527///
528/// * `payload` — the plaintext to encrypt (e.g., DER-encoded certificate).
529/// * `recipient_cert_der` — DER-encoded certificate of the recipient;
530///   the public key is extracted for key transport.
531/// * `content_encryption_alg` — algorithm name or OID for content
532///   encryption (validated via [`validate_content_encryption`]).
533///
534/// # Errors
535///
536/// - `KipukaError::BadRequest` — empty payload, invalid certificate,
537///   unsupported algorithm.
538/// - `KipukaError::Internal` — crypto operations not yet implemented.
539pub fn build_cms_enveloped_data(
540    payload: &[u8],
541    recipient_cert_der: &[u8],
542    content_encryption_alg: &str,
543) -> Result<Vec<u8>, KipukaError> {
544    if payload.is_empty() {
545        return Err(KipukaError::BadRequest(
546            "cannot encrypt empty payload".into(),
547        ));
548    }
549
550    if recipient_cert_der.is_empty() {
551        return Err(KipukaError::BadRequest(
552            "recipient certificate is empty".into(),
553        ));
554    }
555
556    // A valid DER-encoded X.509 certificate is at least ~200 bytes.
557    if recipient_cert_der.len() < 100 {
558        return Err(KipukaError::BadRequest(
559            "recipient certificate is too short to be valid".into(),
560        ));
561    }
562
563    // Validate the requested content encryption algorithm.
564    let _alg = validate_content_encryption(content_encryption_alg)?;
565
566    // OpenSSL emits AuthEnvelopedData for AES-GCM, preserving authenticated
567    // encryption instead of silently substituting CBC.
568    if matches!(
569        _alg,
570        SupportedContentEncryption::Aes256Gcm | SupportedContentEncryption::Aes128Gcm
571    ) {
572        let cipher = if matches!(_alg, SupportedContentEncryption::Aes256Gcm) {
573            openssl::symm::Cipher::aes_256_gcm()
574        } else {
575            openssl::symm::Cipher::aes_128_gcm()
576        };
577        let build = || -> Result<Vec<u8>, openssl::error::ErrorStack> {
578            let mut recipients = openssl::stack::Stack::new()?;
579            recipients.push(openssl::x509::X509::from_der(recipient_cert_der)?)?;
580            openssl::cms::CmsContentInfo::encrypt(
581                &recipients,
582                payload,
583                cipher,
584                openssl::cms::CMSOptions::BINARY,
585            )?
586            .to_der()
587        };
588        return build().map_err(|e| {
589            KipukaError::Internal(format!("CMS authenticated encryption failed: {e}"))
590        });
591    }
592    let content_enc_oid: &[u32] = match _alg {
593        SupportedContentEncryption::Aes256Cbc => synta_certificate::pkcs12_types::ID_AES256_CBC,
594        SupportedContentEncryption::Aes128Cbc => synta_certificate::pkcs12_types::ID_AES128_CBC,
595        _ => unreachable!("GCM handled above"),
596    };
597
598    // Use RSA-OAEP with SHA-256 for key transport (recommended by RFC 8295).
599    let recipients: Vec<(&[u8], KeyWrapAlgorithm)> =
600        vec![(recipient_cert_der, KeyWrapAlgorithm::RsaOaepSha256)];
601
602    synta_certificate::default_create_enveloped_data(payload, &recipients, content_enc_oid)
603        .map_err(|e| KipukaError::Internal(format!("CMS EnvelopedData construction failed: {e}")))
604}
605
606/// Convert a CMS verification result into the standard [`AuthResult`].
607///
608/// This bridges CMS-based authentication into the same identity model
609/// used by mTLS, OTP, and GSSAPI handlers, allowing CMS-authenticated
610/// requests to flow through the same authorization logic.
611///
612/// The `AuthMethod` is set to `Mtls` because the CMS signer certificate
613/// is functionally equivalent to a TLS client certificate — it proves
614/// possession of the corresponding private key and chains to a trusted CA.
615///
616/// # Arguments
617///
618/// * `cms_result` — a successfully verified CMS SignedData result.
619///
620/// # Errors
621///
622/// Returns `KipukaError::Auth` if the signer identity cannot be extracted
623/// (empty subject DN).
624pub fn extract_signer_identity(
625    cms_result: &CmsVerificationResult,
626) -> Result<AuthResult, KipukaError> {
627    if cms_result.signer_subject_dn.is_empty() {
628        return Err(KipukaError::Auth(
629            "CMS signer certificate has an empty subject DN".into(),
630        ));
631    }
632
633    Ok(AuthResult {
634        identity: cms_result.signer_subject_dn.clone(),
635        // CMS signature-based auth is treated as equivalent to mTLS
636        // for authorization purposes — the signer proved possession
637        // of a private key whose certificate chains to the truststore.
638        method: AuthMethod::Mtls,
639        client_cert_der: Some(cms_result.signer_cert_der.clone()),
640        subject_dn: Some(cms_result.signer_subject_dn.clone()),
641        subject_alt_names: Vec::new(),
642        extended_key_usage: Vec::new(),
643    })
644}
645
646/// Match the signer certificate from the certificates list using the sid
647/// (SignerIdentifier) field.
648///
649/// RFC 5652 §5.3: The sid is either an IssuerAndSerialNumber (SEQUENCE)
650/// or a SubjectKeyIdentifier ([0] IMPLICIT OCTET STRING).
651fn match_signer_cert_by_sid<'a>(
652    sid_raw: &[u8],
653    certs: &'a [Vec<u8>],
654) -> Result<&'a Vec<u8>, KipukaError> {
655    if sid_raw.is_empty() {
656        return Err(KipukaError::BadRequest(
657            "CMS SignerInfo sid is empty".into(),
658        ));
659    }
660
661    // Determine the sid variant by examining the first byte's tag.
662    let first_byte = sid_raw[0];
663
664    if first_byte == 0x30 {
665        // IssuerAndSerialNumber — SEQUENCE tag.
666        let ias = synta_certificate::cms_2009_types::IssuerAndSerialNumber::from_der(sid_raw)
667            .map_err(|e| {
668                KipukaError::BadRequest(format!("CMS sid IssuerAndSerialNumber parse error: {e:?}"))
669            })?;
670
671        for cert_der in certs {
672            if let Ok(cert) = Certificate::from_der(cert_der)
673                && cert.tbs_certificate.issuer.as_bytes() == ias.issuer.as_bytes()
674                && cert.tbs_certificate.serial_number == ias.serial_number
675            {
676                return Ok(cert_der);
677            }
678        }
679        Err(KipukaError::BadRequest(
680            "CMS signer certificate not found by IssuerAndSerialNumber".into(),
681        ))
682    } else if first_byte == 0x80 {
683        // SubjectKeyIdentifier — [0] IMPLICIT OCTET STRING.
684        // Extract the SKI value (skip the tag and length bytes).
685        let mut dec = Decoder::new(sid_raw, Encoding::Ber);
686        dec.read_tag()
687            .map_err(|e| KipukaError::BadRequest(format!("CMS sid SKI tag error: {e:?}")))?;
688        let len = dec
689            .read_length()
690            .map_err(|e| KipukaError::BadRequest(format!("CMS sid SKI length error: {e:?}")))?
691            .definite()
692            .map_err(|e| KipukaError::BadRequest(format!("CMS sid SKI indef error: {e:?}")))?;
693        let ski_value = dec
694            .read_bytes(len)
695            .map_err(|e| KipukaError::BadRequest(format!("CMS sid SKI value error: {e:?}")))?;
696
697        for cert_der in certs {
698            if let Ok(cert) = Certificate::from_der(cert_der) {
699                // Look up the SubjectKeyIdentifier extension (OID 2.5.29.14)
700                // from the certificate's extensions.
701                if let Some(ref exts_raw) = cert.tbs_certificate.extensions
702                    && let Some(ext_value) = synta_certificate::find_extension_value(
703                        exts_raw.as_bytes(),
704                        oids::SUBJECT_KEY_IDENTIFIER,
705                    )
706                {
707                    // The extension value is an OCTET STRING wrapping the key id.
708                    let mut ev_dec = Decoder::new(ext_value, Encoding::Der);
709                    if let Ok(tag) = ev_dec.read_tag()
710                        && tag.number() == 4
711                    {
712                        // OCTET STRING
713                        if let Ok(ev_len) = ev_dec.read_length()
714                            && let Ok(def_len) = ev_len.definite()
715                            && let Ok(key_id) = ev_dec.read_bytes(def_len)
716                            && key_id == ski_value
717                        {
718                            return Ok(cert_der);
719                        }
720                    }
721                }
722            }
723        }
724        Err(KipukaError::BadRequest(
725            "CMS signer certificate not found by SubjectKeyIdentifier".into(),
726        ))
727    } else {
728        // Fallback: if sid format is unrecognised, use the first cert and warn.
729        warn!(
730            first_byte = format_args!("0x{first_byte:02x}"),
731            "CMS SignerIdentifier has unexpected tag, falling back to first certificate"
732        );
733        Ok(&certs[0])
734    }
735}
736
737/// Compute a message digest of `data` using the specified algorithm OID.
738///
739/// Supports SHA-256, SHA-384, SHA-512, and SHA-1 (for legacy compatibility).
740fn compute_digest(alg_oid: &[u32], data: &[u8]) -> Result<Vec<u8>, KipukaError> {
741    if alg_oid == oids::ID_SHA256 {
742        Ok(sha2::Sha256::digest(data).to_vec())
743    } else if alg_oid == oids::ID_SHA384 {
744        Ok(sha2::Sha384::digest(data).to_vec())
745    } else if alg_oid == oids::ID_SHA512 {
746        Ok(sha2::Sha512::digest(data).to_vec())
747    } else if alg_oid == oids::ID_SHA1 {
748        Err(KipukaError::BadRequest(
749            "CMS digest algorithm SHA-1 is not permitted — use SHA-256 or stronger".into(),
750        ))
751    } else {
752        Err(KipukaError::BadRequest(format!(
753            "unsupported CMS digest algorithm OID: {alg_oid:?}"
754        )))
755    }
756}
757
758/// Verify that the message-digest attribute in signedAttrs matches the
759/// expected digest of the eContent payload.
760///
761/// RFC 5652 §11.2: The message-digest attribute value is an OCTET STRING
762/// containing the digest of the eContent.
763fn verify_message_digest_attribute(
764    signed_attrs_bytes: &[u8],
765    expected_digest: &[u8],
766) -> Result<(), KipukaError> {
767    // IMPLICIT field decoding has already removed the [0] tag and length.
768    let mut attrs = Decoder::new(signed_attrs_bytes, Encoding::Der);
769    let mut found = false;
770    while !attrs.is_empty() {
771        let mut attr = attrs
772            .enter_constructed(Tag::universal_constructed(16))
773            .map_err(|e| KipukaError::BadRequest(format!("CMS attr SEQUENCE error: {e}")))?;
774        let oid: synta::ObjectIdentifier = attr
775            .decode()
776            .map_err(|e| KipukaError::BadRequest(format!("CMS attr OID error: {e}")))?;
777        if oid.components() == oids::PKCS9_MESSAGE_DIGEST {
778            if found {
779                return Err(KipukaError::BadRequest(
780                    "duplicate CMS message-digest attribute".into(),
781                ));
782            }
783            let mut values = attr
784                .enter_constructed(Tag::universal_constructed(17))
785                .map_err(|e| {
786                    KipukaError::BadRequest(format!("CMS messageDigest SET error: {e}"))
787                })?;
788            let digest: synta::OctetStringRef = values.decode().map_err(|e| {
789                KipukaError::BadRequest(format!("CMS messageDigest OCTET STRING error: {e}"))
790            })?;
791            if !values.is_empty() || !attr.is_empty() {
792                return Err(KipukaError::BadRequest(
793                    "CMS message-digest requires exactly one value".into(),
794                ));
795            }
796            if digest.as_bytes() != expected_digest {
797                return Err(KipukaError::Auth(
798                    "CMS message-digest attribute does not match eContent hash".into(),
799                ));
800            }
801            found = true;
802        } else {
803            while !attr.is_empty() {
804                let _: synta::RawDer = attr
805                    .decode()
806                    .map_err(|e| KipukaError::BadRequest(format!("CMS attr value error: {e}")))?;
807            }
808        }
809    }
810    if !found {
811        return Err(KipukaError::BadRequest(
812            "CMS signedAttrs is missing the required message-digest attribute".into(),
813        ));
814    }
815    Ok(())
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn validate_content_encryption_accepts_aes256gcm() {
824        let result = validate_content_encryption("aes-256-gcm");
825        assert!(result.is_ok());
826        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes256Gcm);
827    }
828
829    #[test]
830    fn validate_content_encryption_accepts_oid() {
831        let result = validate_content_encryption("2.16.840.1.101.3.4.1.46");
832        assert!(result.is_ok());
833        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes256Gcm);
834    }
835
836    #[test]
837    fn validate_content_encryption_rejects_unknown() {
838        let result = validate_content_encryption("triple-des-cbc");
839        assert!(result.is_err());
840    }
841
842    #[test]
843    fn validate_content_encryption_case_insensitive() {
844        let result = validate_content_encryption("AES-128-GCM");
845        assert!(result.is_ok());
846        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes128Gcm);
847    }
848
849    #[test]
850    fn verify_rejects_empty_input() {
851        let result = verify_cms_signed_data(&[], &[vec![0u8; 200]]);
852        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
853    }
854
855    #[test]
856    fn verify_rejects_short_input() {
857        let result = verify_cms_signed_data(&[0u8; 50], &[vec![0u8; 200]]);
858        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
859    }
860
861    #[test]
862    fn verify_rejects_empty_truststore() {
863        let result = verify_cms_signed_data(&[0u8; 200], &[]);
864        assert!(matches!(result, Err(KipukaError::Auth(_))));
865    }
866
867    #[test]
868    fn build_enveloped_rejects_empty_payload() {
869        let result = build_cms_enveloped_data(&[], &[0u8; 200], "aes-256-gcm");
870        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
871    }
872
873    #[test]
874    fn build_enveloped_rejects_empty_cert() {
875        let result = build_cms_enveloped_data(&[1, 2, 3], &[], "aes-256-gcm");
876        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
877    }
878
879    #[test]
880    fn build_enveloped_rejects_bad_algorithm() {
881        let result = build_cms_enveloped_data(&[1, 2, 3], &[0u8; 200], "rc4");
882        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
883    }
884
885    #[test]
886    fn extract_identity_rejects_empty_dn() {
887        let cms = CmsVerificationResult {
888            signer_cert_der: vec![0u8; 100],
889            signer_subject_dn: String::new(),
890            payload: vec![1, 2, 3],
891            signature_algorithm: "sha256WithRSAEncryption".into(),
892        };
893        let auth = extract_signer_identity(&cms);
894        assert!(auth.is_err());
895    }
896
897    #[test]
898    fn extract_identity_produces_valid_auth_result() {
899        let cms = CmsVerificationResult {
900            signer_cert_der: vec![0u8; 100],
901            signer_subject_dn: "CN=client.example.com".into(),
902            payload: vec![1, 2, 3],
903            signature_algorithm: "sha256WithRSAEncryption".into(),
904        };
905        let auth = extract_signer_identity(&cms).unwrap();
906        assert_eq!(auth.identity, "CN=client.example.com");
907        assert_eq!(auth.method, AuthMethod::Mtls);
908        assert!(auth.client_cert_der.is_some());
909        assert_eq!(auth.subject_dn.as_deref(), Some("CN=client.example.com"));
910    }
911}