Skip to main content

uselesskey_core_x509_spec/
spec.rs

1//! X.509 certificate specification.
2
3use std::time::Duration;
4
5/// Key usage flags for X.509 certificates.
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
7pub struct KeyUsage {
8    /// Certificate can sign other certificates (CA).
9    pub key_cert_sign: bool,
10    /// Certificate can sign CRLs.
11    pub crl_sign: bool,
12    /// Certificate can be used for digital signatures.
13    pub digital_signature: bool,
14    /// Certificate can be used for key encipherment.
15    pub key_encipherment: bool,
16}
17
18impl Default for KeyUsage {
19    fn default() -> Self {
20        Self::leaf()
21    }
22}
23
24impl KeyUsage {
25    /// Key usage for a leaf/end-entity certificate.
26    pub fn leaf() -> Self {
27        Self {
28            key_cert_sign: false,
29            crl_sign: false,
30            digital_signature: true,
31            key_encipherment: true,
32        }
33    }
34
35    /// Key usage for a CA certificate.
36    pub fn ca() -> Self {
37        Self {
38            key_cert_sign: true,
39            crl_sign: true,
40            digital_signature: true,
41            key_encipherment: false,
42        }
43    }
44
45    /// Stable byte representation for deterministic derivation.
46    pub fn stable_bytes(&self) -> [u8; 4] {
47        let mut out = [0u8; 4];
48        out[0] = self.key_cert_sign as u8;
49        out[1] = self.crl_sign as u8;
50        out[2] = self.digital_signature as u8;
51        out[3] = self.key_encipherment as u8;
52        out
53    }
54}
55
56/// Specification for generating an X.509 certificate.
57#[derive(Clone, Debug, Eq, PartialEq, Hash)]
58pub struct X509Spec {
59    /// Common Name (CN) for the subject.
60    pub subject_cn: String,
61    /// Common Name (CN) for the issuer (same as subject for self-signed).
62    pub issuer_cn: String,
63    /// Duration before "now" for not_before (negative = in the past).
64    /// Default: 1 day before "now".
65    pub not_before_offset: NotBeforeOffset,
66    /// Duration after "now" for not_after.
67    /// Default: 3650 days (10 years).
68    pub validity_days: u32,
69    /// Key usage flags.
70    pub key_usage: KeyUsage,
71    /// Whether this is a CA certificate.
72    pub is_ca: bool,
73    /// RSA key size in bits.
74    pub rsa_bits: usize,
75    /// DNS Subject Alternative Names.
76    pub sans: Vec<String>,
77}
78
79/// Offset for the not_before field.
80#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
81pub enum NotBeforeOffset {
82    /// Certificate is valid starting from this many days in the past.
83    DaysAgo(u32),
84    /// Certificate is valid starting from this many days in the future.
85    DaysFromNow(u32),
86}
87
88impl Default for NotBeforeOffset {
89    fn default() -> Self {
90        NotBeforeOffset::DaysAgo(1)
91    }
92}
93
94impl Default for X509Spec {
95    fn default() -> Self {
96        Self {
97            subject_cn: "Test Certificate".to_string(),
98            issuer_cn: "Test Certificate".to_string(),
99            not_before_offset: NotBeforeOffset::default(),
100            validity_days: 3650,
101            key_usage: KeyUsage::leaf(),
102            is_ca: false,
103            rsa_bits: 2048,
104            sans: Vec::new(),
105        }
106    }
107}
108
109impl X509Spec {
110    /// Create a spec for a self-signed leaf certificate.
111    pub fn self_signed(cn: impl Into<String>) -> Self {
112        let cn = cn.into();
113        Self {
114            subject_cn: cn.clone(),
115            issuer_cn: cn,
116            ..Default::default()
117        }
118    }
119
120    /// Create a spec for a self-signed CA certificate.
121    pub fn self_signed_ca(cn: impl Into<String>) -> Self {
122        let cn = cn.into();
123        Self {
124            subject_cn: cn.clone(),
125            issuer_cn: cn,
126            key_usage: KeyUsage::ca(),
127            is_ca: true,
128            ..Default::default()
129        }
130    }
131
132    /// Set the validity period in days.
133    pub fn with_validity_days(mut self, days: u32) -> Self {
134        self.validity_days = days;
135        self
136    }
137
138    /// Set the not_before offset.
139    pub fn with_not_before(mut self, offset: NotBeforeOffset) -> Self {
140        self.not_before_offset = offset;
141        self
142    }
143
144    /// Set the RSA key size.
145    pub fn with_rsa_bits(mut self, bits: usize) -> Self {
146        self.rsa_bits = bits;
147        self
148    }
149
150    /// Set key usage flags.
151    pub fn with_key_usage(mut self, key_usage: KeyUsage) -> Self {
152        self.key_usage = key_usage;
153        self
154    }
155
156    /// Set whether this is a CA certificate.
157    pub fn with_is_ca(mut self, is_ca: bool) -> Self {
158        self.is_ca = is_ca;
159        self
160    }
161
162    /// Set DNS Subject Alternative Names.
163    pub fn with_sans(mut self, sans: Vec<String>) -> Self {
164        self.sans = sans;
165        self
166    }
167
168    /// Stable encoding for cache keys / deterministic derivation.
169    ///
170    /// If you change this, bump the derivation version in `uselesskey-core`.
171    pub fn stable_bytes(&self) -> Vec<u8> {
172        let mut out = Vec::new();
173
174        // Version prefix to allow deterministic derivation changes without affecting other crates.
175        // Bump this if X.509 derivation inputs change.
176        // v4: dedup SANs
177        out.push(4);
178
179        // Subject CN length + bytes
180        let subject_bytes = self.subject_cn.as_bytes();
181        out.extend_from_slice(&(subject_bytes.len() as u32).to_be_bytes());
182        out.extend_from_slice(subject_bytes);
183
184        // Issuer CN length + bytes
185        let issuer_bytes = self.issuer_cn.as_bytes();
186        out.extend_from_slice(&(issuer_bytes.len() as u32).to_be_bytes());
187        out.extend_from_slice(issuer_bytes);
188
189        // not_before_offset
190        match self.not_before_offset {
191            NotBeforeOffset::DaysAgo(d) => {
192                out.push(0);
193                out.extend_from_slice(&d.to_be_bytes());
194            }
195            NotBeforeOffset::DaysFromNow(d) => {
196                out.push(1);
197                out.extend_from_slice(&d.to_be_bytes());
198            }
199        }
200
201        // validity_days
202        out.extend_from_slice(&self.validity_days.to_be_bytes());
203
204        // key_usage
205        out.extend_from_slice(&self.key_usage.stable_bytes());
206
207        // is_ca
208        out.push(self.is_ca as u8);
209
210        // rsa_bits
211        out.extend_from_slice(&(self.rsa_bits as u32).to_be_bytes());
212
213        // SANs (sorted and deduplicated for stability)
214        let mut sorted_sans = self.sans.clone();
215        sorted_sans.sort();
216        sorted_sans.dedup();
217        out.extend_from_slice(&(sorted_sans.len() as u32).to_be_bytes());
218        for san in &sorted_sans {
219            let san_bytes = san.as_bytes();
220            out.extend_from_slice(&(san_bytes.len() as u32).to_be_bytes());
221            out.extend_from_slice(san_bytes);
222        }
223
224        out
225    }
226
227    /// Compute the not_before duration from a reference time.
228    pub fn not_before_duration(&self) -> Duration {
229        match self.not_before_offset {
230            NotBeforeOffset::DaysAgo(d) => Duration::from_secs(d as u64 * 24 * 60 * 60),
231            NotBeforeOffset::DaysFromNow(_) => Duration::ZERO,
232        }
233    }
234
235    /// Compute the not_after duration from a reference time.
236    pub fn not_after_duration(&self) -> Duration {
237        let base = match self.not_before_offset {
238            NotBeforeOffset::DaysAgo(_) => Duration::ZERO,
239            NotBeforeOffset::DaysFromNow(d) => Duration::from_secs(d as u64 * 24 * 60 * 60),
240        };
241        base + Duration::from_secs(self.validity_days as u64 * 24 * 60 * 60)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_default_spec() {
251        let spec = X509Spec::default();
252        assert_eq!(spec.subject_cn, "Test Certificate");
253        assert_eq!(spec.issuer_cn, "Test Certificate");
254        assert_eq!(spec.not_before_offset, NotBeforeOffset::DaysAgo(1));
255        assert_eq!(spec.validity_days, 3650);
256        assert_eq!(spec.key_usage, KeyUsage::leaf());
257        assert!(!spec.is_ca);
258        assert_eq!(spec.rsa_bits, 2048);
259        assert!(spec.sans.is_empty());
260    }
261
262    #[test]
263    fn test_key_usage_default_is_leaf() {
264        assert_eq!(KeyUsage::default(), KeyUsage::leaf());
265    }
266
267    #[test]
268    fn test_self_signed_spec() {
269        let spec = X509Spec::self_signed("example.com");
270        assert_eq!(spec.subject_cn, "example.com");
271        assert_eq!(spec.issuer_cn, "example.com");
272        assert!(!spec.is_ca);
273    }
274
275    #[test]
276    fn test_ca_spec() {
277        let spec = X509Spec::self_signed_ca("My CA");
278        assert!(spec.is_ca);
279        assert!(spec.key_usage.key_cert_sign);
280        assert_eq!(spec.subject_cn, "My CA");
281        assert_eq!(spec.issuer_cn, "My CA");
282    }
283
284    #[test]
285    fn test_builder_methods_apply() {
286        let key_usage = KeyUsage::ca();
287        let sans: Vec<String> = vec!["a.example.com".into(), "b.example.com".into()];
288        let spec = X509Spec::self_signed("builder.example.com")
289            .with_validity_days(90)
290            .with_not_before(NotBeforeOffset::DaysFromNow(7))
291            .with_rsa_bits(4096)
292            .with_key_usage(key_usage)
293            .with_is_ca(true)
294            .with_sans(sans.clone());
295
296        assert_eq!(spec.validity_days, 90);
297        assert_eq!(spec.not_before_offset, NotBeforeOffset::DaysFromNow(7));
298        assert_eq!(spec.rsa_bits, 4096);
299        assert!(spec.is_ca);
300        assert_eq!(spec.key_usage, key_usage);
301        assert_eq!(spec.sans, sans);
302    }
303
304    #[test]
305    fn test_not_before_duration_variants() {
306        let days = 3u32;
307        let secs = days as u64 * 24 * 60 * 60;
308
309        let spec_ago = X509Spec::self_signed("ago").with_not_before(NotBeforeOffset::DaysAgo(days));
310        assert_eq!(spec_ago.not_before_duration(), Duration::from_secs(secs));
311
312        let spec_future =
313            X509Spec::self_signed("future").with_not_before(NotBeforeOffset::DaysFromNow(days));
314        assert_eq!(spec_future.not_before_duration(), Duration::ZERO);
315    }
316
317    #[test]
318    fn test_not_after_duration_variants() {
319        let days = 2u32;
320        let secs = days as u64 * 24 * 60 * 60;
321
322        let spec_ago = X509Spec::self_signed("ago").with_validity_days(days);
323        assert_eq!(spec_ago.not_after_duration(), Duration::from_secs(secs));
324
325        let spec_future = X509Spec::self_signed("future")
326            .with_not_before(NotBeforeOffset::DaysFromNow(days))
327            .with_validity_days(days);
328        assert_eq!(
329            spec_future.not_after_duration(),
330            Duration::from_secs(secs * 2)
331        );
332    }
333
334    #[test]
335    fn test_stable_bytes_determinism() {
336        let spec1 = X509Spec::self_signed("test");
337        let spec2 = X509Spec::self_signed("test");
338        assert_eq!(spec1.stable_bytes(), spec2.stable_bytes());
339
340        let spec3 = X509Spec::self_signed("different");
341        assert_ne!(spec1.stable_bytes(), spec3.stable_bytes());
342    }
343
344    #[test]
345    fn test_stable_bytes_deduplicates_sans() {
346        let with_dupes = X509Spec::self_signed("test").with_sans(vec![
347            "a.com".into(),
348            "a.com".into(),
349            "b.com".into(),
350        ]);
351        let without_dupes =
352            X509Spec::self_signed("test").with_sans(vec!["a.com".into(), "b.com".into()]);
353        assert_eq!(with_dupes.stable_bytes(), without_dupes.stable_bytes());
354    }
355
356    #[test]
357    fn test_stable_bytes_field_sensitivity() {
358        let base = X509Spec::self_signed("test");
359        let base_bytes = base.stable_bytes();
360
361        // Changing validity_days changes output
362        let changed = base.clone().with_validity_days(999);
363        assert_ne!(
364            changed.stable_bytes(),
365            base_bytes,
366            "validity_days must affect stable_bytes"
367        );
368
369        // Changing is_ca changes output
370        let changed = base.clone().with_is_ca(true);
371        assert_ne!(
372            changed.stable_bytes(),
373            base_bytes,
374            "is_ca must affect stable_bytes"
375        );
376
377        // Changing rsa_bits changes output
378        let changed = base.clone().with_rsa_bits(4096);
379        assert_ne!(
380            changed.stable_bytes(),
381            base_bytes,
382            "rsa_bits must affect stable_bytes"
383        );
384
385        // Changing not_before_offset changes output
386        let changed = base
387            .clone()
388            .with_not_before(NotBeforeOffset::DaysFromNow(7));
389        assert_ne!(
390            changed.stable_bytes(),
391            base_bytes,
392            "not_before_offset must affect stable_bytes"
393        );
394
395        // Changing key_usage changes output
396        let changed = base.clone().with_key_usage(KeyUsage::ca());
397        assert_ne!(
398            changed.stable_bytes(),
399            base_bytes,
400            "key_usage must affect stable_bytes"
401        );
402
403        // Changing issuer_cn changes output
404        let mut changed = base.clone();
405        changed.issuer_cn = "Other Issuer".to_string();
406        assert_ne!(
407            changed.stable_bytes(),
408            base_bytes,
409            "issuer_cn must affect stable_bytes"
410        );
411
412        // Changing sans changes output
413        let changed = base.clone().with_sans(vec!["san.example.com".into()]);
414        assert_ne!(
415            changed.stable_bytes(),
416            base_bytes,
417            "sans must affect stable_bytes"
418        );
419    }
420
421    #[test]
422    fn test_stable_bytes_not_before_offset_variants_differ() {
423        let days_ago = X509Spec::self_signed("test").with_not_before(NotBeforeOffset::DaysAgo(1));
424        let days_from_now =
425            X509Spec::self_signed("test").with_not_before(NotBeforeOffset::DaysFromNow(1));
426
427        assert_ne!(
428            days_ago.stable_bytes(),
429            days_from_now.stable_bytes(),
430            "DaysAgo(1) and DaysFromNow(1) must produce different stable_bytes (tag byte 0 vs 1)"
431        );
432    }
433}