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}