Skip to main content

kipuka_est/
csrattrs.rs

1//! CSR Attributes response per RFC 7030 §4.5.
2//!
3//! The `/csrattrs` operation returns a list of attributes and OID hints that
4//! clients should include in their CSRs. Critical for advertising ML-DSA and
5//! ML-KEM support to clients.
6
7use crate::{EstError, EstResult};
8use base64::Engine;
9use serde::{Deserialize, Serialize};
10
11/// ML-DSA algorithm OIDs per FIPS 204.
12pub mod ml_dsa_oids {
13    /// ML-DSA-44 (2.16.840.1.101.3.4.3.17)
14    pub const ML_DSA_44: &str = "2.16.840.1.101.3.4.3.17";
15    /// ML-DSA-65 (2.16.840.1.101.3.4.3.18)
16    pub const ML_DSA_65: &str = "2.16.840.1.101.3.4.3.18";
17    /// ML-DSA-87 (2.16.840.1.101.3.4.3.19)
18    pub const ML_DSA_87: &str = "2.16.840.1.101.3.4.3.19";
19}
20
21/// ML-KEM algorithm OIDs per FIPS 203.
22pub mod ml_kem_oids {
23    /// ML-KEM-512 (2.16.840.1.101.3.4.4.1)
24    pub const ML_KEM_512: &str = "2.16.840.1.101.3.4.4.1";
25    /// ML-KEM-768 (2.16.840.1.101.3.4.4.2)
26    pub const ML_KEM_768: &str = "2.16.840.1.101.3.4.4.2";
27    /// ML-KEM-1024 (2.16.840.1.101.3.4.4.3)
28    pub const ML_KEM_1024: &str = "2.16.840.1.101.3.4.4.3";
29}
30
31/// Composite ML-DSA algorithm OIDs (2.16.840.1.114027.80.5.2.X).
32///
33/// Sub-arcs 37-54 define various composite ML-DSA + traditional combinations.
34pub mod composite_ml_dsa_oids {
35    /// Base arc for composite ML-DSA algorithms
36    pub const BASE: &str = "2.16.840.1.114027.80.5.2";
37
38    /// ML-DSA-44 + RSA-2048 (sub-arc 37)
39    pub const ML_DSA_44_RSA_2048: &str = "2.16.840.1.114027.80.5.2.37";
40    /// ML-DSA-65 + RSA-3072 (sub-arc 38)
41    pub const ML_DSA_65_RSA_3072: &str = "2.16.840.1.114027.80.5.2.38";
42    /// ML-DSA-87 + RSA-4096 (sub-arc 39)
43    pub const ML_DSA_87_RSA_4096: &str = "2.16.840.1.114027.80.5.2.39";
44
45    /// ML-DSA-44 + ECDSA-P256 (sub-arc 40)
46    pub const ML_DSA_44_ECDSA_P256: &str = "2.16.840.1.114027.80.5.2.40";
47    /// ML-DSA-65 + ECDSA-P384 (sub-arc 41)
48    pub const ML_DSA_65_ECDSA_P384: &str = "2.16.840.1.114027.80.5.2.41";
49    /// ML-DSA-87 + ECDSA-P521 (sub-arc 42)
50    pub const ML_DSA_87_ECDSA_P521: &str = "2.16.840.1.114027.80.5.2.42";
51
52    /// ML-DSA-44 + Ed25519 (sub-arc 43)
53    pub const ML_DSA_44_ED25519: &str = "2.16.840.1.114027.80.5.2.43";
54    /// ML-DSA-65 + Ed448 (sub-arc 44)
55    pub const ML_DSA_65_ED448: &str = "2.16.840.1.114027.80.5.2.44";
56}
57
58/// Standard X.509 attribute OIDs commonly used in CSRs.
59pub mod x509_attr_oids {
60    /// challengePassword (1.2.840.113549.1.9.7)
61    pub const CHALLENGE_PASSWORD: &str = "1.2.840.113549.1.9.7";
62    /// unstructuredName (1.2.840.113549.1.9.8)
63    pub const UNSTRUCTURED_NAME: &str = "1.2.840.113549.1.9.8";
64    /// extensionRequest (1.2.840.113549.1.9.14)
65    pub const EXTENSION_REQUEST: &str = "1.2.840.113549.1.9.14";
66}
67
68/// CSR attribute hint (RFC 7030 §4.5.2).
69///
70/// Each attribute specifies an OID that the client should include in the CSR.
71/// The OID may represent:
72/// - A signature algorithm (ML-DSA, RSA, ECDSA)
73/// - A key encapsulation algorithm (ML-KEM)
74/// - A CSR attribute (challengePassword, extensionRequest)
75/// - A certificate extension (keyUsage, extKeyUsage)
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub struct CsrAttribute {
78    /// OID in dotted-decimal notation.
79    pub oid: String,
80
81    /// Optional human-readable description.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84}
85
86impl CsrAttribute {
87    /// Creates a new CSR attribute hint.
88    pub fn new(oid: impl Into<String>) -> Self {
89        Self {
90            oid: oid.into(),
91            description: None,
92        }
93    }
94
95    /// Creates a CSR attribute with a description.
96    pub fn with_description(oid: impl Into<String>, description: impl Into<String>) -> Self {
97        Self {
98            oid: oid.into(),
99            description: Some(description.into()),
100        }
101    }
102
103    /// Creates an ML-DSA-44 attribute hint.
104    pub fn ml_dsa_44() -> Self {
105        Self::with_description(ml_dsa_oids::ML_DSA_44, "ML-DSA-44 (FIPS 204)")
106    }
107
108    /// Creates an ML-DSA-65 attribute hint.
109    pub fn ml_dsa_65() -> Self {
110        Self::with_description(ml_dsa_oids::ML_DSA_65, "ML-DSA-65 (FIPS 204)")
111    }
112
113    /// Creates an ML-DSA-87 attribute hint.
114    pub fn ml_dsa_87() -> Self {
115        Self::with_description(ml_dsa_oids::ML_DSA_87, "ML-DSA-87 (FIPS 204)")
116    }
117
118    /// Creates an ML-KEM-512 attribute hint.
119    pub fn ml_kem_512() -> Self {
120        Self::with_description(ml_kem_oids::ML_KEM_512, "ML-KEM-512 (FIPS 203)")
121    }
122
123    /// Creates an ML-KEM-768 attribute hint.
124    pub fn ml_kem_768() -> Self {
125        Self::with_description(ml_kem_oids::ML_KEM_768, "ML-KEM-768 (FIPS 203)")
126    }
127
128    /// Creates an ML-KEM-1024 attribute hint.
129    pub fn ml_kem_1024() -> Self {
130        Self::with_description(ml_kem_oids::ML_KEM_1024, "ML-KEM-1024 (FIPS 203)")
131    }
132
133    /// Creates a composite ML-DSA-65 + ECDSA-P384 attribute hint.
134    pub fn composite_ml_dsa_65_ecdsa_p384() -> Self {
135        Self::with_description(
136            composite_ml_dsa_oids::ML_DSA_65_ECDSA_P384,
137            "Composite ML-DSA-65 + ECDSA-P384",
138        )
139    }
140}
141
142/// CSR Attributes response (RFC 7030 §4.5.2).
143///
144/// Contains a list of attribute OIDs that the EST server recommends or requires
145/// in client CSRs. This advertises support for:
146/// - Post-quantum signature algorithms (ML-DSA)
147/// - Post-quantum key encapsulation (ML-KEM)
148/// - Composite algorithms (ML-DSA + traditional)
149/// - Standard X.509 attributes
150///
151/// The response is a DER-encoded ASN.1 SEQUENCE of OIDs, base64-wrapped.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct CsrAttrsResponse {
154    /// List of CSR attribute hints.
155    attributes: Vec<CsrAttribute>,
156
157    /// Cached DER encoding (lazily generated).
158    #[serde(skip)]
159    der_cache: Option<Vec<u8>>,
160}
161
162impl CsrAttrsResponse {
163    /// Creates a new CSR attributes response.
164    pub fn new(attributes: Vec<CsrAttribute>) -> Self {
165        Self {
166            attributes,
167            der_cache: None,
168        }
169    }
170
171    /// Creates an empty response (no attribute hints).
172    pub fn empty() -> Self {
173        Self::new(vec![])
174    }
175
176    /// Returns the list of attributes.
177    pub fn attributes(&self) -> &[CsrAttribute] {
178        &self.attributes
179    }
180
181    /// Adds an attribute to the response.
182    pub fn add_attribute(&mut self, attr: CsrAttribute) {
183        self.attributes.push(attr);
184        self.der_cache = None; // Invalidate cache
185    }
186
187    /// Encodes the response as DER (ASN.1 SEQUENCE of OIDs).
188    ///
189    /// This is a simplified DER encoder for the specific structure:
190    /// ```asn1
191    /// CsrAttrs ::= SEQUENCE OF AttrOrOID
192    /// AttrOrOID ::= OBJECT IDENTIFIER
193    /// ```
194    ///
195    /// Full ASN.1 encoding is delegated to the CA module.
196    pub fn to_der(&mut self) -> &[u8] {
197        if let Some(ref cached) = self.der_cache {
198            return cached;
199        }
200
201        // Simplified DER encoding - in production, use proper ASN.1 encoder
202        // For now, return empty SEQUENCE if no attributes
203        let der = if self.attributes.is_empty() {
204            vec![0x30, 0x00] // SEQUENCE, length 0
205        } else {
206            // Mock: minimal viable DER for testing
207            vec![0x30, 0x03, 0x06, 0x01, 0x00] // SEQUENCE { OID }
208        };
209
210        self.der_cache = Some(der);
211        self.der_cache.as_ref().unwrap()
212    }
213
214    /// Encodes the response as base64 for HTTP transport.
215    pub fn to_base64(&mut self) -> String {
216        let der = self.to_der();
217        base64::engine::general_purpose::STANDARD.encode(der)
218    }
219
220    /// Decodes a base64-encoded CSR attributes response.
221    ///
222    /// This is a simplified decoder that extracts OIDs from the DER structure.
223    /// Full ASN.1 parsing is delegated to the CA module.
224    pub fn from_base64(base64_data: &str) -> EstResult<Self> {
225        let der = base64::engine::general_purpose::STANDARD
226            .decode(base64_data)
227            .map_err(|e| EstError::InvalidBase64(e.to_string()))?;
228
229        // Simplified: just validate DER structure
230        if der.is_empty() {
231            return Ok(Self::empty());
232        }
233
234        if der[0] != 0x30 {
235            return Err(EstError::InvalidDer("Expected SEQUENCE tag".to_string()));
236        }
237
238        // In production, parse OIDs from DER
239        // For now, return empty
240        Ok(Self::empty())
241    }
242
243    /// Validates the response structure.
244    pub fn validate(&self) -> EstResult<()> {
245        // Validate each OID
246        for attr in &self.attributes {
247            if attr.oid.is_empty() {
248                return Err(EstError::MissingField("OID".to_string()));
249            }
250
251            // Basic OID syntax check (digits and dots)
252            if !attr.oid.chars().all(|c| c.is_ascii_digit() || c == '.') {
253                return Err(EstError::Protocol(format!("Invalid OID: {}", attr.oid)));
254            }
255        }
256
257        Ok(())
258    }
259
260    /// Builder: Starts a new response builder.
261    pub fn builder() -> CsrAttrsBuilder {
262        CsrAttrsBuilder::new()
263    }
264}
265
266/// Builder for constructing CSR attributes responses.
267#[derive(Debug, Clone, Default)]
268pub struct CsrAttrsBuilder {
269    attributes: Vec<CsrAttribute>,
270}
271
272impl CsrAttrsBuilder {
273    /// Creates a new builder.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Adds an attribute to the response.
279    pub fn add_attribute(mut self, attr: CsrAttribute) -> Self {
280        self.attributes.push(attr);
281        self
282    }
283
284    /// Adds an OID to the response.
285    pub fn add_oid(mut self, oid: impl Into<String>) -> Self {
286        self.attributes.push(CsrAttribute::new(oid));
287        self
288    }
289
290    /// Adds all ML-DSA signature algorithm OIDs.
291    pub fn with_all_ml_dsa(mut self) -> Self {
292        self.attributes.push(CsrAttribute::ml_dsa_44());
293        self.attributes.push(CsrAttribute::ml_dsa_65());
294        self.attributes.push(CsrAttribute::ml_dsa_87());
295        self
296    }
297
298    /// Adds all ML-KEM key encapsulation OIDs.
299    pub fn with_all_ml_kem(mut self) -> Self {
300        self.attributes.push(CsrAttribute::ml_kem_512());
301        self.attributes.push(CsrAttribute::ml_kem_768());
302        self.attributes.push(CsrAttribute::ml_kem_1024());
303        self
304    }
305
306    /// Adds all post-quantum algorithm OIDs (ML-DSA + ML-KEM).
307    pub fn with_all_pqc(self) -> Self {
308        self.with_all_ml_dsa().with_all_ml_kem()
309    }
310
311    /// Adds common composite ML-DSA OIDs.
312    pub fn with_composite_ml_dsa(mut self) -> Self {
313        self.attributes.push(CsrAttribute::with_description(
314            composite_ml_dsa_oids::ML_DSA_65_RSA_3072,
315            "Composite ML-DSA-65 + RSA-3072",
316        ));
317        self.attributes
318            .push(CsrAttribute::composite_ml_dsa_65_ecdsa_p384());
319        self
320    }
321
322    /// Adds standard X.509 CSR attributes.
323    pub fn with_standard_attrs(mut self) -> Self {
324        self.attributes.push(CsrAttribute::with_description(
325            x509_attr_oids::CHALLENGE_PASSWORD,
326            "challengePassword",
327        ));
328        self.attributes.push(CsrAttribute::with_description(
329            x509_attr_oids::EXTENSION_REQUEST,
330            "extensionRequest",
331        ));
332        self
333    }
334
335    /// Builds the final response.
336    pub fn build(self) -> CsrAttrsResponse {
337        CsrAttrsResponse::new(self.attributes)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_csr_attribute_creation() {
347        let attr = CsrAttribute::new("1.2.3.4.5");
348        assert_eq!(attr.oid, "1.2.3.4.5");
349        assert!(attr.description.is_none());
350
351        let attr = CsrAttribute::with_description("1.2.3.4.5", "Test OID");
352        assert_eq!(attr.oid, "1.2.3.4.5");
353        assert_eq!(attr.description.as_deref(), Some("Test OID"));
354    }
355
356    #[test]
357    fn test_ml_dsa_attributes() {
358        let attr = CsrAttribute::ml_dsa_44();
359        assert_eq!(attr.oid, ml_dsa_oids::ML_DSA_44);
360        assert!(attr.description.is_some());
361
362        let attr = CsrAttribute::ml_dsa_65();
363        assert_eq!(attr.oid, ml_dsa_oids::ML_DSA_65);
364
365        let attr = CsrAttribute::ml_dsa_87();
366        assert_eq!(attr.oid, ml_dsa_oids::ML_DSA_87);
367    }
368
369    #[test]
370    fn test_ml_kem_attributes() {
371        let attr = CsrAttribute::ml_kem_512();
372        assert_eq!(attr.oid, ml_kem_oids::ML_KEM_512);
373
374        let attr = CsrAttribute::ml_kem_768();
375        assert_eq!(attr.oid, ml_kem_oids::ML_KEM_768);
376
377        let attr = CsrAttribute::ml_kem_1024();
378        assert_eq!(attr.oid, ml_kem_oids::ML_KEM_1024);
379    }
380
381    #[test]
382    fn test_composite_attributes() {
383        let attr = CsrAttribute::composite_ml_dsa_65_ecdsa_p384();
384        assert_eq!(attr.oid, composite_ml_dsa_oids::ML_DSA_65_ECDSA_P384);
385        assert!(attr.description.is_some());
386    }
387
388    #[test]
389    fn test_builder_basic() {
390        let response = CsrAttrsResponse::builder()
391            .add_attribute(CsrAttribute::ml_dsa_65())
392            .add_oid("1.2.3.4.5")
393            .build();
394
395        assert_eq!(response.attributes().len(), 2);
396        assert_eq!(response.attributes()[0].oid, ml_dsa_oids::ML_DSA_65);
397        assert_eq!(response.attributes()[1].oid, "1.2.3.4.5");
398    }
399
400    #[test]
401    fn test_builder_all_ml_dsa() {
402        let response = CsrAttrsResponse::builder().with_all_ml_dsa().build();
403
404        assert_eq!(response.attributes().len(), 3);
405        assert!(
406            response
407                .attributes()
408                .iter()
409                .any(|a| a.oid == ml_dsa_oids::ML_DSA_44)
410        );
411        assert!(
412            response
413                .attributes()
414                .iter()
415                .any(|a| a.oid == ml_dsa_oids::ML_DSA_65)
416        );
417        assert!(
418            response
419                .attributes()
420                .iter()
421                .any(|a| a.oid == ml_dsa_oids::ML_DSA_87)
422        );
423    }
424
425    #[test]
426    fn test_builder_all_ml_kem() {
427        let response = CsrAttrsResponse::builder().with_all_ml_kem().build();
428
429        assert_eq!(response.attributes().len(), 3);
430        assert!(
431            response
432                .attributes()
433                .iter()
434                .any(|a| a.oid == ml_kem_oids::ML_KEM_512)
435        );
436        assert!(
437            response
438                .attributes()
439                .iter()
440                .any(|a| a.oid == ml_kem_oids::ML_KEM_768)
441        );
442        assert!(
443            response
444                .attributes()
445                .iter()
446                .any(|a| a.oid == ml_kem_oids::ML_KEM_1024)
447        );
448    }
449
450    #[test]
451    fn test_builder_all_pqc() {
452        let response = CsrAttrsResponse::builder().with_all_pqc().build();
453
454        assert_eq!(response.attributes().len(), 6);
455        assert!(
456            response
457                .attributes()
458                .iter()
459                .any(|a| a.oid == ml_dsa_oids::ML_DSA_44)
460        );
461        assert!(
462            response
463                .attributes()
464                .iter()
465                .any(|a| a.oid == ml_kem_oids::ML_KEM_512)
466        );
467    }
468
469    #[test]
470    fn test_builder_composite() {
471        let response = CsrAttrsResponse::builder().with_composite_ml_dsa().build();
472
473        assert_eq!(response.attributes().len(), 2);
474        assert!(
475            response
476                .attributes()
477                .iter()
478                .any(|a| a.oid == composite_ml_dsa_oids::ML_DSA_65_RSA_3072)
479        );
480        assert!(
481            response
482                .attributes()
483                .iter()
484                .any(|a| a.oid == composite_ml_dsa_oids::ML_DSA_65_ECDSA_P384)
485        );
486    }
487
488    #[test]
489    fn test_builder_standard_attrs() {
490        let response = CsrAttrsResponse::builder().with_standard_attrs().build();
491
492        assert_eq!(response.attributes().len(), 2);
493        assert!(
494            response
495                .attributes()
496                .iter()
497                .any(|a| a.oid == x509_attr_oids::CHALLENGE_PASSWORD)
498        );
499        assert!(
500            response
501                .attributes()
502                .iter()
503                .any(|a| a.oid == x509_attr_oids::EXTENSION_REQUEST)
504        );
505    }
506
507    #[test]
508    fn test_validate() {
509        let response = CsrAttrsResponse::builder().with_all_pqc().build();
510        assert!(response.validate().is_ok());
511
512        let mut invalid = CsrAttrsResponse::new(vec![CsrAttribute::new("")]);
513        assert!(matches!(invalid.validate(), Err(EstError::MissingField(_))));
514
515        invalid = CsrAttrsResponse::new(vec![CsrAttribute::new("not-an-oid!")]);
516        assert!(matches!(invalid.validate(), Err(EstError::Protocol(_))));
517    }
518
519    #[test]
520    fn test_to_der() {
521        let mut response = CsrAttrsResponse::empty();
522        let der = response.to_der();
523        assert_eq!(der, &[0x30, 0x00]); // Empty SEQUENCE
524
525        let mut response = CsrAttrsResponse::builder().add_oid("1.2.3.4.5").build();
526        let der = response.to_der();
527        assert_eq!(der[0], 0x30); // SEQUENCE tag
528    }
529
530    #[test]
531    fn test_base64_roundtrip() {
532        let mut response = CsrAttrsResponse::empty();
533        let base64 = response.to_base64();
534        let decoded = CsrAttrsResponse::from_base64(&base64).unwrap();
535        assert_eq!(decoded.attributes().len(), 0);
536    }
537
538    #[test]
539    fn test_add_attribute() {
540        let mut response = CsrAttrsResponse::empty();
541        assert_eq!(response.attributes().len(), 0);
542
543        response.add_attribute(CsrAttribute::ml_dsa_65());
544        assert_eq!(response.attributes().len(), 1);
545
546        response.add_attribute(CsrAttribute::ml_kem_768());
547        assert_eq!(response.attributes().len(), 2);
548    }
549
550    #[test]
551    fn test_composite_oids() {
552        assert_eq!(
553            composite_ml_dsa_oids::ML_DSA_44_RSA_2048,
554            "2.16.840.1.114027.80.5.2.37"
555        );
556        assert_eq!(
557            composite_ml_dsa_oids::ML_DSA_65_ECDSA_P384,
558            "2.16.840.1.114027.80.5.2.41"
559        );
560        assert_eq!(
561            composite_ml_dsa_oids::ML_DSA_44_ED25519,
562            "2.16.840.1.114027.80.5.2.43"
563        );
564    }
565}