Skip to main content

uselesskey_x509/srp/spec/
chain_spec.rs

1//! X.509 certificate chain specification.
2
3use super::{KeyUsage, NotBeforeOffset};
4
5/// Specification for generating a three-level X.509 certificate chain
6/// (root CA -> intermediate CA -> leaf).
7#[derive(Clone, Debug, Eq, PartialEq, Hash)]
8pub struct ChainSpec {
9    /// Common Name (CN) for the leaf certificate.
10    pub leaf_cn: String,
11    /// DNS Subject Alternative Names for the leaf certificate.
12    pub leaf_sans: Vec<String>,
13    /// Common Name (CN) for the root CA.
14    pub root_cn: String,
15    /// Common Name (CN) for the intermediate CA.
16    pub intermediate_cn: String,
17    /// RSA key size in bits.
18    pub rsa_bits: usize,
19    /// Root CA validity period in days.
20    pub root_validity_days: u32,
21    /// Intermediate CA validity period in days.
22    pub intermediate_validity_days: u32,
23    /// Leaf certificate validity period in days.
24    pub leaf_validity_days: u32,
25    /// Override for leaf `not_before` relative to the deterministic base time.
26    ///
27    /// When `None`, `not_before = base_time - 1 day` (the default).
28    pub leaf_not_before: Option<NotBeforeOffset>,
29    /// Override for intermediate `not_before` relative to the deterministic base time.
30    ///
31    /// When `None`, `not_before = base_time - 1 day` (the default).
32    pub intermediate_not_before: Option<NotBeforeOffset>,
33    /// Optional override for whether the intermediate claims CA status.
34    ///
35    /// When `None`, the intermediate remains a CA.
36    pub intermediate_is_ca: Option<bool>,
37    /// Optional override for the intermediate key usage bits.
38    ///
39    /// When `None`, the intermediate uses standard CA key usage.
40    pub intermediate_key_usage: Option<KeyUsage>,
41}
42
43impl ChainSpec {
44    /// Create a chain spec with sensible defaults for the given leaf CN.
45    ///
46    /// The leaf CN is automatically added to the SAN list.
47    pub fn new(leaf_cn: impl Into<String>) -> Self {
48        let leaf_cn = leaf_cn.into();
49        let root_cn = format!("{} Root CA", leaf_cn);
50        let intermediate_cn = format!("{} Intermediate CA", leaf_cn);
51        let leaf_sans = vec![leaf_cn.clone()];
52        Self {
53            leaf_cn,
54            leaf_sans,
55            root_cn,
56            intermediate_cn,
57            rsa_bits: 2048,
58            root_validity_days: 3650,
59            intermediate_validity_days: 1825,
60            leaf_validity_days: 3650,
61            leaf_not_before: None,
62            intermediate_not_before: None,
63            intermediate_is_ca: None,
64            intermediate_key_usage: None,
65        }
66    }
67
68    /// Set the DNS Subject Alternative Names for the leaf certificate.
69    ///
70    /// The leaf CN is **not** automatically added; include it explicitly if needed.
71    pub fn with_sans(mut self, sans: Vec<String>) -> Self {
72        self.leaf_sans = sans;
73        self
74    }
75
76    /// Set the root CA Common Name.
77    pub fn with_root_cn(mut self, cn: impl Into<String>) -> Self {
78        self.root_cn = cn.into();
79        self
80    }
81
82    /// Set the intermediate CA Common Name.
83    pub fn with_intermediate_cn(mut self, cn: impl Into<String>) -> Self {
84        self.intermediate_cn = cn.into();
85        self
86    }
87
88    /// Set the RSA key size in bits.
89    pub fn with_rsa_bits(mut self, bits: usize) -> Self {
90        self.rsa_bits = bits;
91        self
92    }
93
94    /// Set the root CA validity period in days.
95    pub fn with_root_validity_days(mut self, days: u32) -> Self {
96        self.root_validity_days = days;
97        self
98    }
99
100    /// Set the intermediate CA validity period in days.
101    pub fn with_intermediate_validity_days(mut self, days: u32) -> Self {
102        self.intermediate_validity_days = days;
103        self
104    }
105
106    /// Set the leaf certificate validity period in days.
107    pub fn with_leaf_validity_days(mut self, days: u32) -> Self {
108        self.leaf_validity_days = days;
109        self
110    }
111
112    /// Set the leaf `not_before` override.
113    pub fn with_leaf_not_before(mut self, offset: NotBeforeOffset) -> Self {
114        self.leaf_not_before = Some(offset);
115        self
116    }
117
118    /// Set the intermediate `not_before` override.
119    pub fn with_intermediate_not_before(mut self, offset: NotBeforeOffset) -> Self {
120        self.intermediate_not_before = Some(offset);
121        self
122    }
123
124    /// Override whether the intermediate claims CA status.
125    pub fn with_intermediate_is_ca(mut self, is_ca: bool) -> Self {
126        self.intermediate_is_ca = Some(is_ca);
127        self
128    }
129
130    /// Override the intermediate key usage bits.
131    pub fn with_intermediate_key_usage(mut self, key_usage: KeyUsage) -> Self {
132        self.intermediate_key_usage = Some(key_usage);
133        self
134    }
135
136    /// Stable byte representation for deterministic derivation.
137    ///
138    /// SANs are sorted and deduplicated before encoding for stability.
139    ///
140    /// For backward compatibility, specs that only use the pre-#279 surface
141    /// keep the legacy v2 encoding so existing good/expired chain fixtures do
142    /// not drift. Richer time offsets and intermediate overrides use v3.
143    pub fn stable_bytes(&self) -> Vec<u8> {
144        if self.uses_v2_compat_encoding() {
145            return self.stable_bytes_v2_compat();
146        }
147
148        self.stable_bytes_v3()
149    }
150
151    fn uses_v2_compat_encoding(&self) -> bool {
152        self.intermediate_is_ca.is_none()
153            && self.intermediate_key_usage.is_none()
154            && supports_v2_not_before(self.leaf_not_before)
155            && supports_v2_not_before(self.intermediate_not_before)
156    }
157
158    fn stable_bytes_v2_compat(&self) -> Vec<u8> {
159        let mut out = Vec::new();
160
161        // Version prefix (v2: pre-#279 ChainSpec encoding)
162        out.push(2);
163        encode_common_fields(self, &mut out);
164        encode_optional_days_ago_i64(&mut out, self.leaf_not_before);
165        encode_optional_days_ago_i64(&mut out, self.intermediate_not_before);
166        out
167    }
168
169    fn stable_bytes_v3(&self) -> Vec<u8> {
170        let mut out = Vec::new();
171
172        // Version prefix (v3: rich not_before offsets + intermediate overrides)
173        out.push(3);
174        encode_common_fields(self, &mut out);
175
176        // not_before offsets and intermediate overrides
177        encode_optional_not_before(&mut out, self.leaf_not_before);
178        encode_optional_not_before(&mut out, self.intermediate_not_before);
179
180        match self.intermediate_is_ca {
181            None => out.push(0),
182            Some(false) => out.push(1),
183            Some(true) => out.push(2),
184        }
185
186        match self.intermediate_key_usage {
187            None => out.push(0),
188            Some(key_usage) => {
189                out.push(1);
190                out.extend_from_slice(&key_usage.stable_bytes());
191            }
192        }
193
194        out
195    }
196}
197
198fn encode_common_fields(spec: &ChainSpec, out: &mut Vec<u8>) {
199    // leaf_cn
200    let leaf_cn_bytes = spec.leaf_cn.as_bytes();
201    out.extend_from_slice(&(leaf_cn_bytes.len() as u32).to_be_bytes());
202    out.extend_from_slice(leaf_cn_bytes);
203
204    // leaf_sans (sorted and deduplicated for stability)
205    let mut sorted_sans = spec.leaf_sans.clone();
206    sorted_sans.sort();
207    sorted_sans.dedup();
208    out.extend_from_slice(&(sorted_sans.len() as u32).to_be_bytes());
209    for san in &sorted_sans {
210        let san_bytes = san.as_bytes();
211        out.extend_from_slice(&(san_bytes.len() as u32).to_be_bytes());
212        out.extend_from_slice(san_bytes);
213    }
214
215    // root_cn
216    let root_cn_bytes = spec.root_cn.as_bytes();
217    out.extend_from_slice(&(root_cn_bytes.len() as u32).to_be_bytes());
218    out.extend_from_slice(root_cn_bytes);
219
220    // intermediate_cn
221    let int_cn_bytes = spec.intermediate_cn.as_bytes();
222    out.extend_from_slice(&(int_cn_bytes.len() as u32).to_be_bytes());
223    out.extend_from_slice(int_cn_bytes);
224
225    // rsa_bits
226    out.extend_from_slice(&(spec.rsa_bits as u32).to_be_bytes());
227
228    // validity periods
229    out.extend_from_slice(&spec.root_validity_days.to_be_bytes());
230    out.extend_from_slice(&spec.intermediate_validity_days.to_be_bytes());
231    out.extend_from_slice(&spec.leaf_validity_days.to_be_bytes());
232}
233
234fn supports_v2_not_before(offset: Option<NotBeforeOffset>) -> bool {
235    matches!(offset, None | Some(NotBeforeOffset::DaysAgo(_)))
236}
237
238fn encode_optional_days_ago_i64(out: &mut Vec<u8>, offset: Option<NotBeforeOffset>) {
239    match offset {
240        None => out.push(0),
241        Some(NotBeforeOffset::DaysAgo(days)) => {
242            out.push(1);
243            out.extend_from_slice(&i64::from(days).to_be_bytes());
244        }
245        Some(NotBeforeOffset::DaysFromNow(_)) => {
246            unreachable!("DaysFromNow requires v3 encoding")
247        }
248    }
249}
250
251fn encode_optional_not_before(out: &mut Vec<u8>, offset: Option<NotBeforeOffset>) {
252    match offset {
253        None => out.push(0),
254        Some(NotBeforeOffset::DaysAgo(days)) => {
255            out.push(1);
256            out.extend_from_slice(&days.to_be_bytes());
257        }
258        Some(NotBeforeOffset::DaysFromNow(days)) => {
259            out.push(2);
260            out.extend_from_slice(&days.to_be_bytes());
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_defaults() {
271        let spec = ChainSpec::new("test.example.com");
272        assert_eq!(spec.leaf_cn, "test.example.com");
273        assert_eq!(spec.leaf_sans, vec!["test.example.com"]);
274        assert_eq!(spec.root_cn, "test.example.com Root CA");
275        assert_eq!(spec.intermediate_cn, "test.example.com Intermediate CA");
276        assert_eq!(spec.rsa_bits, 2048);
277        assert_eq!(spec.root_validity_days, 3650);
278        assert_eq!(spec.intermediate_validity_days, 1825);
279        assert_eq!(spec.leaf_validity_days, 3650);
280        assert_eq!(spec.leaf_not_before, None);
281        assert_eq!(spec.intermediate_not_before, None);
282        assert_eq!(spec.intermediate_is_ca, None);
283        assert_eq!(spec.intermediate_key_usage, None);
284    }
285
286    #[test]
287    fn test_builders() {
288        let spec = ChainSpec::new("example.com")
289            .with_sans(vec![
290                "example.com".to_string(),
291                "www.example.com".to_string(),
292            ])
293            .with_root_cn("My Root CA")
294            .with_intermediate_cn("My Int CA")
295            .with_rsa_bits(4096)
296            .with_root_validity_days(7300)
297            .with_intermediate_validity_days(3650)
298            .with_leaf_validity_days(90)
299            .with_leaf_not_before(NotBeforeOffset::DaysFromNow(7))
300            .with_intermediate_not_before(NotBeforeOffset::DaysAgo(30))
301            .with_intermediate_is_ca(false)
302            .with_intermediate_key_usage(KeyUsage::leaf());
303
304        assert_eq!(spec.leaf_sans.len(), 2);
305        assert_eq!(spec.root_cn, "My Root CA");
306        assert_eq!(spec.intermediate_cn, "My Int CA");
307        assert_eq!(spec.rsa_bits, 4096);
308        assert_eq!(spec.root_validity_days, 7300);
309        assert_eq!(spec.intermediate_validity_days, 3650);
310        assert_eq!(spec.leaf_validity_days, 90);
311        assert_eq!(spec.leaf_not_before, Some(NotBeforeOffset::DaysFromNow(7)));
312        assert_eq!(
313            spec.intermediate_not_before,
314            Some(NotBeforeOffset::DaysAgo(30))
315        );
316        assert_eq!(spec.intermediate_is_ca, Some(false));
317        assert_eq!(spec.intermediate_key_usage, Some(KeyUsage::leaf()));
318    }
319
320    #[test]
321    fn test_stable_bytes_determinism() {
322        let spec1 = ChainSpec::new("test.example.com");
323        let spec2 = ChainSpec::new("test.example.com");
324        assert_eq!(spec1.stable_bytes(), spec2.stable_bytes());
325
326        let spec3 = ChainSpec::new("other.example.com");
327        assert_ne!(spec1.stable_bytes(), spec3.stable_bytes());
328    }
329
330    #[test]
331    fn test_stable_bytes_san_order_independent() {
332        let spec1 = ChainSpec::new("test.example.com").with_sans(vec![
333            "a.example.com".to_string(),
334            "b.example.com".to_string(),
335        ]);
336        let spec2 = ChainSpec::new("test.example.com").with_sans(vec![
337            "b.example.com".to_string(),
338            "a.example.com".to_string(),
339        ]);
340        assert_eq!(spec1.stable_bytes(), spec2.stable_bytes());
341    }
342
343    #[test]
344    fn test_stable_bytes_field_sensitivity() {
345        let base = ChainSpec::new("test.example.com");
346        let base_bytes = base.stable_bytes();
347
348        // Changing rsa_bits
349        let changed = base.clone().with_rsa_bits(4096);
350        assert_ne!(
351            changed.stable_bytes(),
352            base_bytes,
353            "rsa_bits must affect stable_bytes"
354        );
355
356        // Changing root_validity_days
357        let changed = base.clone().with_root_validity_days(999);
358        assert_ne!(
359            changed.stable_bytes(),
360            base_bytes,
361            "root_validity_days must affect stable_bytes"
362        );
363
364        // Changing intermediate_validity_days
365        let changed = base.clone().with_intermediate_validity_days(999);
366        assert_ne!(
367            changed.stable_bytes(),
368            base_bytes,
369            "intermediate_validity_days must affect stable_bytes"
370        );
371
372        // Changing leaf_validity_days
373        let changed = base.clone().with_leaf_validity_days(999);
374        assert_ne!(
375            changed.stable_bytes(),
376            base_bytes,
377            "leaf_validity_days must affect stable_bytes"
378        );
379
380        // Changing root_cn
381        let changed = base.clone().with_root_cn("Other Root CA");
382        assert_ne!(
383            changed.stable_bytes(),
384            base_bytes,
385            "root_cn must affect stable_bytes"
386        );
387
388        // Changing intermediate_cn
389        let changed = base.clone().with_intermediate_cn("Other Int CA");
390        assert_ne!(
391            changed.stable_bytes(),
392            base_bytes,
393            "intermediate_cn must affect stable_bytes"
394        );
395
396        // Changing leaf_sans
397        let changed = base
398            .clone()
399            .with_sans(vec!["extra.example.com".to_string()]);
400        assert_ne!(
401            changed.stable_bytes(),
402            base_bytes,
403            "leaf_sans must affect stable_bytes"
404        );
405    }
406
407    #[test]
408    fn test_stable_bytes_optional_offset_sensitivity() {
409        let base = ChainSpec::new("test.example.com");
410        let base_bytes = base.stable_bytes();
411
412        // leaf_not_before_offset_days: None vs Some(100)
413        let mut with_leaf_offset = base.clone();
414        with_leaf_offset.leaf_not_before = Some(NotBeforeOffset::DaysAgo(100));
415        assert_ne!(
416            with_leaf_offset.stable_bytes(),
417            base_bytes,
418            "leaf_not_before None vs Some must differ"
419        );
420
421        // leaf_not_before: DaysAgo(100) vs DaysFromNow(100)
422        let mut with_leaf_offset2 = base.clone();
423        with_leaf_offset2.leaf_not_before = Some(NotBeforeOffset::DaysFromNow(100));
424        assert_ne!(
425            with_leaf_offset.stable_bytes(),
426            with_leaf_offset2.stable_bytes(),
427            "leaf_not_before days-ago vs days-from-now must differ"
428        );
429
430        // intermediate_not_before: None vs Some(100)
431        let mut with_int_offset = base.clone();
432        with_int_offset.intermediate_not_before = Some(NotBeforeOffset::DaysAgo(100));
433        assert_ne!(
434            with_int_offset.stable_bytes(),
435            base_bytes,
436            "intermediate_not_before None vs Some must differ"
437        );
438
439        // intermediate_not_before: Some(100) vs Some(200)
440        let mut with_int_offset2 = base.clone();
441        with_int_offset2.intermediate_not_before = Some(NotBeforeOffset::DaysAgo(200));
442        assert_ne!(
443            with_int_offset.stable_bytes(),
444            with_int_offset2.stable_bytes(),
445            "intermediate_not_before Some(100) vs Some(200) must differ"
446        );
447
448        let with_int_is_ca = base.clone().with_intermediate_is_ca(false);
449        assert_ne!(
450            with_int_is_ca.stable_bytes(),
451            base_bytes,
452            "intermediate_is_ca must affect stable_bytes"
453        );
454
455        let with_int_ku = base.clone().with_intermediate_key_usage(KeyUsage::leaf());
456        assert_ne!(
457            with_int_ku.stable_bytes(),
458            base_bytes,
459            "intermediate_key_usage must affect stable_bytes"
460        );
461    }
462
463    #[test]
464    fn test_stable_bytes_v3_encodes_not_before_offsets() {
465        let base = ChainSpec::new("test.example.com").with_intermediate_is_ca(false);
466        let base_bytes = base.stable_bytes();
467
468        let leaf_future = base
469            .clone()
470            .with_leaf_not_before(NotBeforeOffset::DaysFromNow(7));
471        assert_ne!(
472            leaf_future.stable_bytes(),
473            base_bytes,
474            "v3 leaf not_before offset must affect stable_bytes"
475        );
476
477        let leaf_past = base
478            .clone()
479            .with_leaf_not_before(NotBeforeOffset::DaysAgo(7));
480        assert_ne!(
481            leaf_future.stable_bytes(),
482            leaf_past.stable_bytes(),
483            "v3 leaf days-from-now and days-ago offsets must differ"
484        );
485
486        let intermediate_future =
487            base.with_intermediate_not_before(NotBeforeOffset::DaysFromNow(7));
488        assert_ne!(
489            intermediate_future.stable_bytes(),
490            base_bytes,
491            "v3 intermediate not_before offset must affect stable_bytes"
492        );
493    }
494
495    #[test]
496    fn test_stable_bytes_default_uses_v2_compat_prefix() {
497        let spec = ChainSpec::new("compat.example.com");
498        assert_eq!(spec.stable_bytes()[0], 2);
499    }
500
501    #[test]
502    fn test_stable_bytes_days_ago_only_stays_on_v2_compat() {
503        let spec = ChainSpec::new("compat.example.com")
504            .with_leaf_not_before(NotBeforeOffset::DaysAgo(7))
505            .with_intermediate_not_before(NotBeforeOffset::DaysAgo(30));
506        assert_eq!(spec.stable_bytes()[0], 2);
507    }
508
509    #[test]
510    fn test_stable_bytes_days_from_now_or_intermediate_overrides_use_v3() {
511        let future = ChainSpec::new("future.example.com")
512            .with_leaf_not_before(NotBeforeOffset::DaysFromNow(7));
513        assert_eq!(future.stable_bytes()[0], 3);
514
515        let not_ca = ChainSpec::new("path.example.com").with_intermediate_is_ca(false);
516        assert_eq!(not_ca.stable_bytes()[0], 3);
517
518        let wrong_ku =
519            ChainSpec::new("path.example.com").with_intermediate_key_usage(KeyUsage::leaf());
520        assert_eq!(wrong_ku.stable_bytes()[0], 3);
521    }
522}