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 crate::auth::{AuthMethod, AuthResult};
17use crate::error::KipukaError;
18
19/// Result of verifying a CMS SignedData message (RFC 8295 §3.1).
20///
21/// After successful verification, the signer's certificate and the
22/// unwrapped payload (typically a PKCS#10 CSR) are available for
23/// further processing by the EST handler.
24#[derive(Debug, Clone)]
25pub struct CmsVerificationResult {
26    /// DER-encoded signer certificate extracted from the SignedData.
27    ///
28    /// RFC 8295 §3.1: The signer's certificate is included in the
29    /// `certificates` field of the SignedData and MUST chain to a
30    /// trust anchor in the EST truststore.
31    pub signer_cert_der: Vec<u8>,
32
33    /// Subject DN of the signer certificate as a string.
34    ///
35    /// Used for identity extraction and audit logging.
36    pub signer_subject_dn: String,
37
38    /// The unwrapped payload extracted from the SignedData `encapContentInfo`.
39    ///
40    /// For EST operations this is typically a DER-encoded PKCS#10 CSR.
41    pub payload: Vec<u8>,
42
43    /// Signature algorithm OID or name used to sign the CMS message.
44    ///
45    /// Verified against the server's allowed algorithm list to reject
46    /// weak algorithms (e.g., MD5, SHA-1).
47    pub signature_algorithm: String,
48}
49
50/// Content encryption algorithms supported for CMS EnvelopedData.
51///
52/// RFC 8295 §3.2: The EST server encrypts the response to the client's
53/// public key.  Only AEAD or CBC modes with NIST-approved ciphers are
54/// permitted.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SupportedContentEncryption {
57    /// AES-256-GCM (OID 2.16.840.1.101.3.4.1.46).
58    Aes256Gcm,
59    /// AES-128-GCM (OID 2.16.840.1.101.3.4.1.6).
60    Aes128Gcm,
61    /// AES-256-CBC (OID 2.16.840.1.101.3.4.1.42).
62    Aes256Cbc,
63    /// AES-128-CBC (OID 2.16.840.1.101.3.4.1.2).
64    Aes128Cbc,
65}
66
67/// Validate a content encryption algorithm string and map it to a
68/// supported variant.
69///
70/// Accepts OID strings or short names (e.g., `"aes-256-gcm"`,
71/// `"2.16.840.1.101.3.4.1.46"`).
72///
73/// # Errors
74///
75/// Returns `KipukaError::BadRequest` if the algorithm is not recognised
76/// or not permitted by policy.
77pub fn validate_content_encryption(alg: &str) -> Result<SupportedContentEncryption, KipukaError> {
78    match alg.to_ascii_lowercase().as_str() {
79        "aes-256-gcm" | "aes256gcm" | "2.16.840.1.101.3.4.1.46" => {
80            Ok(SupportedContentEncryption::Aes256Gcm)
81        }
82        "aes-128-gcm" | "aes128gcm" | "2.16.840.1.101.3.4.1.6" => {
83            Ok(SupportedContentEncryption::Aes128Gcm)
84        }
85        "aes-256-cbc" | "aes256cbc" | "2.16.840.1.101.3.4.1.42" => {
86            Ok(SupportedContentEncryption::Aes256Cbc)
87        }
88        "aes-128-cbc" | "aes128cbc" | "2.16.840.1.101.3.4.1.2" => {
89            Ok(SupportedContentEncryption::Aes128Cbc)
90        }
91        _ => Err(KipukaError::BadRequest(format!(
92            "unsupported content encryption algorithm: {alg}"
93        ))),
94    }
95}
96
97/// Verify a CMS SignedData message and extract the payload.
98///
99/// RFC 8295 §3.1: The EST server performs the following steps:
100///
101/// 1. Parse the outer ContentInfo (DER) and verify `contentType` is
102///    `id-signedData` (OID 1.2.840.113549.1.7.2).
103/// 2. Extract the `SignerInfo` — exactly one signer is expected for EST.
104/// 3. Locate the signer's certificate in the `certificates` field.
105/// 4. Verify the signature using the signer's public key and the
106///    `digestAlgorithm` + `signatureAlgorithm` from `SignerInfo`.
107/// 5. Validate the signer's certificate chain against `truststore`:
108///    - Build a chain from the signer cert to a trust anchor.
109///    - Check validity periods (notBefore/notAfter).
110///    - Check revocation status (CRL/OCSP) if configured.
111/// 6. Extract the `eContent` from `encapContentInfo` — the unwrapped
112///    payload (CSR).
113///
114/// # Arguments
115///
116/// * `signed_data_der` — DER-encoded CMS ContentInfo containing SignedData.
117/// * `truststore` — DER-encoded trust anchor certificates to verify
118///   the signer's certificate chain against.
119///
120/// # Errors
121///
122/// - `KipukaError::BadRequest` — malformed CMS, missing signer, empty payload.
123/// - `KipukaError::Auth` — signature verification failure, untrusted signer.
124/// - `KipukaError::Internal` — crypto operations not yet implemented.
125pub fn verify_cms_signed_data(
126    signed_data_der: &[u8],
127    truststore: &[Vec<u8>],
128) -> Result<CmsVerificationResult, KipukaError> {
129    // Input validation.
130    if signed_data_der.is_empty() {
131        return Err(KipukaError::BadRequest("CMS SignedData is empty".into()));
132    }
133
134    // A minimal CMS ContentInfo with SignedData is at least ~100 bytes:
135    // ContentInfo SEQUENCE header + OID + SignedData structure.
136    if signed_data_der.len() < 100 {
137        return Err(KipukaError::BadRequest(
138            "CMS SignedData is too short to be valid".into(),
139        ));
140    }
141
142    if truststore.is_empty() {
143        return Err(KipukaError::Auth(
144            "CMS truststore is empty — cannot verify signer certificate".into(),
145        ));
146    }
147
148    // TODO: Implement CMS SignedData verification.
149    //
150    // Implementation plan:
151    //
152    // 1. Parse ContentInfo from `signed_data_der`:
153    //    let content_info = cms::ContentInfo::from_der(signed_data_der)?;
154    //    assert content_info.content_type == id_signedData;
155    //
156    // 2. Parse SignedData from content_info.content:
157    //    let signed_data = cms::SignedData::from_der(&content_info.content)?;
158    //
159    // 3. Extract signer info (exactly one signer for EST):
160    //    let signer_info = signed_data.signer_infos.first()
161    //        .ok_or(KipukaError::BadRequest("no signer in CMS"))?;
162    //
163    // 4. Resolve signer certificate from the certificates field:
164    //    let signer_cert = signed_data.certificates
165    //        .find_by_sid(&signer_info.sid)?;
166    //
167    // 5. Verify signature:
168    //    signer_info.verify_signature(
169    //        &signer_cert.public_key(),
170    //        &signed_data.encap_content_info,
171    //    )?;
172    //
173    // 6. Validate signer cert chain against truststore:
174    //    x509::verify_chain(&signer_cert, &signed_data.certificates, truststore)?;
175    //
176    // 7. Extract payload:
177    //    let payload = signed_data.encap_content_info.econtent
178    //        .ok_or(KipukaError::BadRequest("no encapsulated content"))?;
179    //
180    // 8. Return result:
181    //    Ok(CmsVerificationResult {
182    //        signer_cert_der: signer_cert.to_der()?,
183    //        signer_subject_dn: signer_cert.subject().to_string(),
184    //        payload,
185    //        signature_algorithm: signer_info.signature_algorithm.oid.to_string(),
186    //    })
187
188    Err(KipukaError::Internal(
189        "CMS SignedData verification not yet implemented".into(),
190    ))
191}
192
193/// Build a CMS EnvelopedData message to encrypt a response payload.
194///
195/// RFC 8295 §3.2: The EST server encrypts the response (issued
196/// certificate) to the client's public key so that only the client
197/// can decrypt it, even if the transport layer is plain HTTP.
198///
199/// The construction follows RFC 5652 §6 (EnvelopedData):
200///
201/// 1. Generate a random content-encryption key (CEK) for the selected
202///    algorithm (`content_encryption_alg`).
203/// 2. Encrypt `payload` with the CEK to produce the `encryptedContent`.
204/// 3. Encrypt the CEK to the recipient's public key (from
205///    `recipient_cert_der`) using `KeyTransRecipientInfo` (ktri).
206/// 4. Assemble the EnvelopedData:
207///    - `version`: 0 (ktri with issuerAndSerialNumber)
208///    - `recipientInfos`: one KeyTransRecipientInfo
209///    - `encryptedContentInfo`: the encrypted payload
210/// 5. Wrap in ContentInfo with `contentType` = `id-envelopedData`
211///    (OID 1.2.840.113549.1.7.3).
212/// 6. Return the DER-encoded ContentInfo.
213///
214/// # Arguments
215///
216/// * `payload` — the plaintext to encrypt (e.g., DER-encoded certificate).
217/// * `recipient_cert_der` — DER-encoded certificate of the recipient;
218///   the public key is extracted for key transport.
219/// * `content_encryption_alg` — algorithm name or OID for content
220///   encryption (validated via [`validate_content_encryption`]).
221///
222/// # Errors
223///
224/// - `KipukaError::BadRequest` — empty payload, invalid certificate,
225///   unsupported algorithm.
226/// - `KipukaError::Internal` — crypto operations not yet implemented.
227pub fn build_cms_enveloped_data(
228    payload: &[u8],
229    recipient_cert_der: &[u8],
230    content_encryption_alg: &str,
231) -> Result<Vec<u8>, KipukaError> {
232    if payload.is_empty() {
233        return Err(KipukaError::BadRequest(
234            "cannot encrypt empty payload".into(),
235        ));
236    }
237
238    if recipient_cert_der.is_empty() {
239        return Err(KipukaError::BadRequest(
240            "recipient certificate is empty".into(),
241        ));
242    }
243
244    // A valid DER-encoded X.509 certificate is at least ~200 bytes.
245    if recipient_cert_der.len() < 100 {
246        return Err(KipukaError::BadRequest(
247            "recipient certificate is too short to be valid".into(),
248        ));
249    }
250
251    // Validate the requested content encryption algorithm.
252    let _alg = validate_content_encryption(content_encryption_alg)?;
253
254    // TODO: Implement CMS EnvelopedData construction.
255    //
256    // Implementation plan:
257    //
258    // 1. Parse recipient certificate:
259    //    let cert = x509::Certificate::from_der(recipient_cert_der)?;
260    //    let pub_key = cert.subject_public_key_info();
261    //
262    // 2. Generate random CEK for the content encryption algorithm:
263    //    let cek = alg.generate_key()?;
264    //
265    // 3. Encrypt payload with CEK:
266    //    let (encrypted_content, iv) = alg.encrypt(&cek, payload)?;
267    //
268    // 4. Encrypt CEK to recipient public key (RSAES-OAEP or similar):
269    //    let encrypted_key = pub_key.encrypt_key(&cek)?;
270    //
271    // 5. Build KeyTransRecipientInfo:
272    //    let ktri = KeyTransRecipientInfo {
273    //        version: 0,
274    //        rid: IssuerAndSerialNumber::from(&cert),
275    //        key_encryption_algorithm: rsaes_oaep(),
276    //        encrypted_key,
277    //    };
278    //
279    // 6. Build EnvelopedData:
280    //    let env_data = EnvelopedData {
281    //        version: 0,
282    //        recipient_infos: vec![ktri.into()],
283    //        encrypted_content_info: EncryptedContentInfo {
284    //            content_type: id_data(),
285    //            content_encryption_algorithm: alg.to_algorithm_identifier(iv),
286    //            encrypted_content: Some(encrypted_content),
287    //        },
288    //    };
289    //
290    // 7. Wrap in ContentInfo and encode:
291    //    let content_info = ContentInfo {
292    //        content_type: id_envelopedData(),
293    //        content: env_data.to_der()?,
294    //    };
295    //    Ok(content_info.to_der()?)
296
297    Err(KipukaError::Internal(
298        "CMS EnvelopedData construction not yet implemented".into(),
299    ))
300}
301
302/// Convert a CMS verification result into the standard [`AuthResult`].
303///
304/// This bridges CMS-based authentication into the same identity model
305/// used by mTLS, OTP, and GSSAPI handlers, allowing CMS-authenticated
306/// requests to flow through the same authorization logic.
307///
308/// The `AuthMethod` is set to `Mtls` because the CMS signer certificate
309/// is functionally equivalent to a TLS client certificate — it proves
310/// possession of the corresponding private key and chains to a trusted CA.
311///
312/// # Arguments
313///
314/// * `cms_result` — a successfully verified CMS SignedData result.
315///
316/// # Errors
317///
318/// Returns `KipukaError::Auth` if the signer identity cannot be extracted
319/// (empty subject DN).
320pub fn extract_signer_identity(
321    cms_result: &CmsVerificationResult,
322) -> Result<AuthResult, KipukaError> {
323    if cms_result.signer_subject_dn.is_empty() {
324        return Err(KipukaError::Auth(
325            "CMS signer certificate has an empty subject DN".into(),
326        ));
327    }
328
329    Ok(AuthResult {
330        identity: cms_result.signer_subject_dn.clone(),
331        // CMS signature-based auth is treated as equivalent to mTLS
332        // for authorization purposes — the signer proved possession
333        // of a private key whose certificate chains to the truststore.
334        method: AuthMethod::Mtls,
335        client_cert_der: Some(cms_result.signer_cert_der.clone()),
336        subject_dn: Some(cms_result.signer_subject_dn.clone()),
337        subject_alt_names: Vec::new(),
338        extended_key_usage: Vec::new(),
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn validate_content_encryption_accepts_aes256gcm() {
348        let result = validate_content_encryption("aes-256-gcm");
349        assert!(result.is_ok());
350        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes256Gcm);
351    }
352
353    #[test]
354    fn validate_content_encryption_accepts_oid() {
355        let result = validate_content_encryption("2.16.840.1.101.3.4.1.46");
356        assert!(result.is_ok());
357        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes256Gcm);
358    }
359
360    #[test]
361    fn validate_content_encryption_rejects_unknown() {
362        let result = validate_content_encryption("triple-des-cbc");
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn validate_content_encryption_case_insensitive() {
368        let result = validate_content_encryption("AES-128-GCM");
369        assert!(result.is_ok());
370        assert_eq!(result.unwrap(), SupportedContentEncryption::Aes128Gcm);
371    }
372
373    #[test]
374    fn verify_rejects_empty_input() {
375        let result = verify_cms_signed_data(&[], &[vec![0u8; 200]]);
376        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
377    }
378
379    #[test]
380    fn verify_rejects_short_input() {
381        let result = verify_cms_signed_data(&[0u8; 50], &[vec![0u8; 200]]);
382        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
383    }
384
385    #[test]
386    fn verify_rejects_empty_truststore() {
387        let result = verify_cms_signed_data(&[0u8; 200], &[]);
388        assert!(matches!(result, Err(KipukaError::Auth(_))));
389    }
390
391    #[test]
392    fn build_enveloped_rejects_empty_payload() {
393        let result = build_cms_enveloped_data(&[], &[0u8; 200], "aes-256-gcm");
394        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
395    }
396
397    #[test]
398    fn build_enveloped_rejects_empty_cert() {
399        let result = build_cms_enveloped_data(&[1, 2, 3], &[], "aes-256-gcm");
400        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
401    }
402
403    #[test]
404    fn build_enveloped_rejects_bad_algorithm() {
405        let result = build_cms_enveloped_data(&[1, 2, 3], &[0u8; 200], "rc4");
406        assert!(matches!(result, Err(KipukaError::BadRequest(_))));
407    }
408
409    #[test]
410    fn extract_identity_rejects_empty_dn() {
411        let cms = CmsVerificationResult {
412            signer_cert_der: vec![0u8; 100],
413            signer_subject_dn: String::new(),
414            payload: vec![1, 2, 3],
415            signature_algorithm: "sha256WithRSAEncryption".into(),
416        };
417        let auth = extract_signer_identity(&cms);
418        assert!(auth.is_err());
419    }
420
421    #[test]
422    fn extract_identity_produces_valid_auth_result() {
423        let cms = CmsVerificationResult {
424            signer_cert_der: vec![0u8; 100],
425            signer_subject_dn: "CN=client.example.com".into(),
426            payload: vec![1, 2, 3],
427            signature_algorithm: "sha256WithRSAEncryption".into(),
428        };
429        let auth = extract_signer_identity(&cms).unwrap();
430        assert_eq!(auth.identity, "CN=client.example.com");
431        assert_eq!(auth.method, AuthMethod::Mtls);
432        assert!(auth.client_cert_der.is_some());
433        assert_eq!(auth.subject_dn.as_deref(), Some("CN=client.example.com"));
434    }
435}