Skip to main content

kipuka_est/
reenroll.rs

1//! Simple re-enrollment per RFC 7030 §4.2.2.
2//!
3//! The `/simplereenroll` operation renews an existing certificate using mTLS.
4//! The subject in the CSR must match the mTLS client certificate subject.
5//!
6//! CSR format follows RFC 2986 (PKCS#10). The [`CertificationRequest`] struct
7//! from [`crate::enroll`] is reused for structured CSR access.
8
9use crate::enroll::{CertificationRequest, EnrollRequest, EnrollResponse};
10use crate::{EstError, EstResult};
11use serde::{Deserialize, Serialize};
12
13/// Re-enrollment request (RFC 7030 §4.2.2).
14///
15/// Identical wire format to `EnrollRequest`, but with additional requirements:
16/// - The client MUST present a valid certificate via mTLS
17/// - The CSR subject MUST match the mTLS certificate subject
18/// - The CSR public key MAY differ (key rotation)
19///
20/// Subject matching is enforced by the EST server before processing.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ReenrollRequest {
23    /// Inner enrollment request (PKCS#10 CSR).
24    #[serde(flatten)]
25    inner: EnrollRequest,
26}
27
28impl ReenrollRequest {
29    /// Creates a new re-enrollment request from a DER-encoded PKCS#10 CSR.
30    pub fn new(csr_der: Vec<u8>) -> Self {
31        Self {
32            inner: EnrollRequest::new(csr_der),
33        }
34    }
35
36    /// Creates from an existing `EnrollRequest`.
37    pub fn from_enroll_request(inner: EnrollRequest) -> Self {
38        Self { inner }
39    }
40
41    /// Returns the inner enrollment request.
42    pub fn inner(&self) -> &EnrollRequest {
43        &self.inner
44    }
45
46    /// Consumes self and returns the inner enrollment request.
47    pub fn into_inner(self) -> EnrollRequest {
48        self.inner
49    }
50
51    /// Returns the raw DER-encoded CSR.
52    pub fn csr_der(&self) -> &[u8] {
53        self.inner.csr_der()
54    }
55
56    /// Consumes self and returns the DER-encoded CSR.
57    pub fn into_csr_der(self) -> Vec<u8> {
58        self.inner.into_csr_der()
59    }
60
61    /// Encodes the request as base64 for HTTP transport.
62    pub fn to_base64(&self) -> String {
63        self.inner.to_base64()
64    }
65
66    /// Decodes a base64-encoded re-enrollment request.
67    pub fn from_base64(base64_data: &str) -> EstResult<Self> {
68        let inner = EnrollRequest::from_base64(base64_data)?;
69        Ok(Self { inner })
70    }
71
72    /// Validates the CSR structure.
73    pub fn validate(&self) -> EstResult<()> {
74        self.inner.validate()
75    }
76
77    /// Validates subject matching between CSR and mTLS client certificate.
78    ///
79    /// # Arguments
80    ///
81    /// * `mtls_subject` - Distinguished name from mTLS client certificate
82    /// * `csr_subject` - Distinguished name from CSR (parsed by caller)
83    ///
84    /// # Errors
85    ///
86    /// Returns `EstError::SubjectMismatch` if subjects don't match.
87    ///
88    /// # Note
89    ///
90    /// Subject parsing is delegated to the caller (CA module) since it requires
91    /// full X.509 ASN.1 parsing. This method only compares the pre-parsed values.
92    pub fn validate_subject_match(&self, mtls_subject: &str, csr_subject: &str) -> EstResult<()> {
93        if mtls_subject != csr_subject {
94            return Err(EstError::SubjectMismatch {
95                expected: mtls_subject.to_string(),
96                actual: csr_subject.to_string(),
97            });
98        }
99        Ok(())
100    }
101
102    /// Checks if the CSR appears to contain an ML-DSA public key.
103    pub fn contains_ml_dsa(&self) -> bool {
104        self.inner.contains_ml_dsa()
105    }
106
107    /// Checks if the CSR appears to contain an ML-KEM public key.
108    pub fn contains_ml_kem(&self) -> bool {
109        self.inner.contains_ml_kem()
110    }
111
112    /// Returns a parsed [`CertificationRequest`] from the inner DER-encoded CSR.
113    ///
114    /// Delegates to [`EnrollRequest::to_certification_request`]. The CA module
115    /// populates the struct fields from actual ASN.1 parsing.
116    pub fn to_certification_request(&self) -> CertificationRequest {
117        self.inner.to_certification_request()
118    }
119}
120
121/// Re-enrollment response (RFC 7030 §4.2.2).
122///
123/// Identical wire format to `EnrollResponse`. Contains the renewed certificate
124/// chain as a PKCS#7 certs-only structure.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct ReenrollResponse {
127    /// Inner enrollment response (PKCS#7 cert chain).
128    #[serde(flatten)]
129    inner: EnrollResponse,
130}
131
132impl ReenrollResponse {
133    /// Creates a new re-enrollment response from DER-encoded PKCS#7.
134    pub fn new(pkcs7_der: Vec<u8>) -> Self {
135        Self {
136            inner: EnrollResponse::new(pkcs7_der),
137        }
138    }
139
140    /// Creates from an existing `EnrollResponse`.
141    pub fn from_enroll_response(inner: EnrollResponse) -> Self {
142        Self { inner }
143    }
144
145    /// Returns the inner enrollment response.
146    pub fn inner(&self) -> &EnrollResponse {
147        &self.inner
148    }
149
150    /// Consumes self and returns the inner enrollment response.
151    pub fn into_inner(self) -> EnrollResponse {
152        self.inner
153    }
154
155    /// Returns the raw DER-encoded PKCS#7 data.
156    pub fn pkcs7_der(&self) -> &[u8] {
157        self.inner.pkcs7_der()
158    }
159
160    /// Consumes self and returns the DER-encoded PKCS#7 data.
161    pub fn into_pkcs7_der(self) -> Vec<u8> {
162        self.inner.into_pkcs7_der()
163    }
164
165    /// Encodes the response as base64 for HTTP transport.
166    pub fn to_base64(&self) -> String {
167        self.inner.to_base64()
168    }
169
170    /// Decodes a base64-encoded re-enrollment response.
171    pub fn from_base64(base64_data: &str) -> EstResult<Self> {
172        let inner = EnrollResponse::from_base64(base64_data)?;
173        Ok(Self { inner })
174    }
175
176    /// Validates the PKCS#7 structure.
177    pub fn validate(&self) -> EstResult<()> {
178        self.inner.validate()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_reenroll_request_roundtrip() {
188        let mut der = vec![0x30, 0x82, 0x01, 0x00];
189        der.extend(vec![0x00; 252]);
190
191        let request = ReenrollRequest::new(der.clone());
192        assert_eq!(request.csr_der(), &der);
193
194        let base64 = request.to_base64();
195        let decoded = ReenrollRequest::from_base64(&base64).unwrap();
196        assert_eq!(decoded.csr_der(), &der);
197    }
198
199    #[test]
200    fn test_reenroll_response_roundtrip() {
201        let mut der = vec![0x30, 0x82, 0x01, 0x00];
202        der.extend(vec![0x00; 252]);
203
204        let response = ReenrollResponse::new(der.clone());
205        assert_eq!(response.pkcs7_der(), &der);
206
207        let base64 = response.to_base64();
208        let decoded = ReenrollResponse::from_base64(&base64).unwrap();
209        assert_eq!(decoded.pkcs7_der(), &der);
210    }
211
212    #[test]
213    fn test_subject_match_success() {
214        let mut der = vec![0x30, 0x82, 0x01, 0x00];
215        der.extend(vec![0x00; 252]);
216
217        let request = ReenrollRequest::new(der);
218        let mtls_subject = "CN=client.example.com,O=Example,C=US";
219        let csr_subject = "CN=client.example.com,O=Example,C=US";
220
221        assert!(
222            request
223                .validate_subject_match(mtls_subject, csr_subject)
224                .is_ok()
225        );
226    }
227
228    #[test]
229    fn test_subject_match_failure() {
230        let mut der = vec![0x30, 0x82, 0x01, 0x00];
231        der.extend(vec![0x00; 252]);
232
233        let request = ReenrollRequest::new(der);
234        let mtls_subject = "CN=client.example.com,O=Example,C=US";
235        let csr_subject = "CN=attacker.evil.com,O=Evil,C=XX";
236
237        let result = request.validate_subject_match(mtls_subject, csr_subject);
238        assert!(matches!(result, Err(EstError::SubjectMismatch { .. })));
239
240        if let Err(EstError::SubjectMismatch { expected, actual }) = result {
241            assert_eq!(expected, mtls_subject);
242            assert_eq!(actual, csr_subject);
243        }
244    }
245
246    #[test]
247    fn test_from_enroll_request() {
248        let mut der = vec![0x30, 0x82, 0x01, 0x00];
249        der.extend(vec![0x00; 252]);
250
251        let enroll_req = EnrollRequest::new(der.clone());
252        let reenroll_req = ReenrollRequest::from_enroll_request(enroll_req);
253
254        assert_eq!(reenroll_req.csr_der(), &der);
255    }
256
257    #[test]
258    fn test_from_enroll_response() {
259        let mut der = vec![0x30, 0x82, 0x01, 0x00];
260        der.extend(vec![0x00; 252]);
261
262        let enroll_resp = EnrollResponse::new(der.clone());
263        let reenroll_resp = ReenrollResponse::from_enroll_response(enroll_resp);
264
265        assert_eq!(reenroll_resp.pkcs7_der(), &der);
266    }
267
268    #[test]
269    fn test_validate() {
270        let mut der = vec![0x30, 0x82, 0x01, 0x00];
271        der.extend(vec![0x00; 252]);
272
273        let request = ReenrollRequest::new(der);
274        assert!(request.validate().is_ok());
275    }
276
277    #[test]
278    fn test_ml_dsa_detection() {
279        let mut der = vec![0x30, 0x82, 0x01, 0x00];
280        der.extend_from_slice(b"\x06\x0b\x60\x86\x48\x01\x65\x03\x04\x03\x11");
281        der.extend(vec![0x00; 240]);
282
283        let request = ReenrollRequest::new(der);
284        assert!(request.contains_ml_dsa());
285        assert!(!request.contains_ml_kem());
286    }
287
288    #[test]
289    fn test_to_certification_request() {
290        let mut der = vec![0x30, 0x82, 0x01, 0x00];
291        der.extend(vec![0x00; 252]);
292
293        let request = ReenrollRequest::new(der.clone());
294        let cr = request.to_certification_request();
295        assert_eq!(cr.version, 0);
296        assert_eq!(cr.tbs_der, der);
297    }
298}