Skip to main content

hoike_sign/
rotation.rs

1use der::{Decode, DecodePem};
2use tracing::{error, info, warn};
3use x509_cert::Certificate;
4
5use crate::error::{Result, SignError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum RotationStatus {
9    Ok { expires_in_secs: u64 },
10    RenewSoon { expires_in_secs: u64 },
11    Expired,
12}
13
14pub fn parse_certificate(bytes: &[u8]) -> der::Result<Certificate> {
15    if bytes.starts_with(b"-----BEGIN") {
16        Certificate::from_pem(bytes)
17    } else {
18        Certificate::from_der(bytes)
19    }
20}
21
22pub fn check_rotation_needed(cert_der: &[u8], renew_before_secs: u64) -> Result<RotationStatus> {
23    let cert = parse_certificate(cert_der)
24        .map_err(|e| SignError::Config(format!("failed to parse responder certificate: {e}")))?;
25
26    let not_after = cert.tbs_certificate.validity.not_after;
27    let not_after_epoch = time_to_epoch(not_after);
28
29    let now = std::time::SystemTime::now()
30        .duration_since(std::time::UNIX_EPOCH)
31        .unwrap_or_default()
32        .as_secs();
33
34    if now >= not_after_epoch {
35        return Ok(RotationStatus::Expired);
36    }
37
38    let expires_in_secs = not_after_epoch - now;
39
40    if expires_in_secs <= renew_before_secs {
41        Ok(RotationStatus::RenewSoon { expires_in_secs })
42    } else {
43        Ok(RotationStatus::Ok { expires_in_secs })
44    }
45}
46
47pub fn check_and_log_rotation(
48    ca_label: &str,
49    cert_der: &[u8],
50    renew_before_secs: u64,
51) -> Result<RotationStatus> {
52    let status = check_rotation_needed(cert_der, renew_before_secs)?;
53
54    match &status {
55        RotationStatus::Ok { expires_in_secs } => {
56            let days = expires_in_secs / 86400;
57            info!(
58                ca = ca_label,
59                expires_in_days = days,
60                "OCSP signing certificate valid"
61            );
62        }
63        RotationStatus::RenewSoon { expires_in_secs } => {
64            let days = expires_in_secs / 86400;
65            let hours = (expires_in_secs % 86400) / 3600;
66            warn!(
67                ca = ca_label,
68                expires_in_days = days,
69                expires_in_hours = hours,
70                "OCSP signing certificate approaching expiry — rotation needed"
71            );
72        }
73        RotationStatus::Expired => {
74            error!(
75                ca = ca_label,
76                "OCSP signing certificate has EXPIRED — responses will be rejected by clients"
77            );
78        }
79    }
80
81    Ok(status)
82}
83
84pub fn run_rotation_command(ca_label: &str, command: &str) -> std::result::Result<(), String> {
85    info!(ca = ca_label, command, "executing rotation command");
86
87    let output = std::process::Command::new("sh")
88        .arg("-c")
89        .arg(command)
90        .output()
91        .map_err(|e| format!("failed to execute rotation command: {e}"))?;
92
93    if output.status.success() {
94        info!(ca = ca_label, "rotation command completed successfully");
95        Ok(())
96    } else {
97        let stderr = String::from_utf8_lossy(&output.stderr);
98        error!(
99            ca = ca_label,
100            exit_code = output.status.code().unwrap_or(-1),
101            stderr = %stderr,
102            "rotation command failed"
103        );
104        Err(format!(
105            "rotation command exited with {}: {}",
106            output.status,
107            stderr.trim()
108        ))
109    }
110}
111
112pub fn format_cert_info(cert_der: &[u8]) -> std::result::Result<CertInfo, String> {
113    let cert =
114        parse_certificate(cert_der).map_err(|e| format!("failed to parse certificate: {e}"))?;
115
116    let tbs = &cert.tbs_certificate;
117
118    let not_before = time_to_epoch(tbs.validity.not_before);
119    let not_after = time_to_epoch(tbs.validity.not_after);
120    let now = std::time::SystemTime::now()
121        .duration_since(std::time::UNIX_EPOCH)
122        .unwrap_or_default()
123        .as_secs();
124
125    let has_ocsp_signing = tbs
126        .extensions
127        .as_ref()
128        .and_then(|exts| {
129            exts.iter()
130                .find(|ext| ext.extn_id == const_oid::ObjectIdentifier::new_unwrap("2.5.29.37"))
131        })
132        .map(|ext| {
133            ext.extn_value
134                .as_bytes()
135                .windows(9)
136                .any(|w| w == [0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x01, 0x09])
137                || ext
138                    .extn_value
139                    .as_bytes()
140                    .windows(8)
141                    .any(|w| w == [43, 6, 1, 5, 5, 7, 3, 9])
142        })
143        .unwrap_or(false);
144
145    Ok(CertInfo {
146        subject: format!("{}", tbs.subject),
147        issuer: format!("{}", tbs.issuer),
148        not_before,
149        not_after,
150        is_expired: now >= not_after,
151        days_remaining: if now < not_after {
152            (not_after - now) / 86400
153        } else {
154            0
155        },
156        has_ocsp_signing_eku: has_ocsp_signing,
157    })
158}
159
160#[derive(Debug)]
161pub struct CertInfo {
162    pub subject: String,
163    pub issuer: String,
164    pub not_before: u64,
165    pub not_after: u64,
166    pub is_expired: bool,
167    pub days_remaining: u64,
168    pub has_ocsp_signing_eku: bool,
169}
170
171fn time_to_epoch(time: x509_cert::time::Time) -> u64 {
172    let dt = match time {
173        x509_cert::time::Time::UtcTime(t) => t.to_date_time(),
174        x509_cert::time::Time::GeneralTime(t) => t.to_date_time(),
175    };
176    crate::generate::datetime_to_epoch(dt)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use der::Encode;
183
184    fn build_test_cert(not_before_dt: der::DateTime, not_after_dt: der::DateTime) -> Vec<u8> {
185        use der::asn1::BitString;
186        use spki::AlgorithmIdentifierOwned;
187        use x509_cert::name::RdnSequence;
188        use x509_cert::time::Time;
189
190        let sha256_ecdsa = const_oid::ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2");
191
192        let nb = Time::UtcTime(der::asn1::UtcTime::from_date_time(not_before_dt).unwrap());
193        let na = Time::UtcTime(der::asn1::UtcTime::from_date_time(not_after_dt).unwrap());
194
195        let tbs = x509_cert::TbsCertificate {
196            version: x509_cert::Version::V3,
197            serial_number: x509_cert::serial_number::SerialNumber::new(&[1]).unwrap(),
198            signature: AlgorithmIdentifierOwned {
199                oid: sha256_ecdsa,
200                parameters: None,
201            },
202            issuer: RdnSequence::default(),
203            validity: x509_cert::time::Validity {
204                not_before: nb,
205                not_after: na,
206            },
207            subject: RdnSequence::default(),
208            subject_public_key_info: spki::SubjectPublicKeyInfoOwned {
209                algorithm: AlgorithmIdentifierOwned {
210                    oid: sha256_ecdsa,
211                    parameters: None,
212                },
213                subject_public_key: BitString::from_bytes(&[0u8; 65]).unwrap(),
214            },
215            issuer_unique_id: None,
216            subject_unique_id: None,
217            extensions: None,
218        };
219
220        let cert = Certificate {
221            tbs_certificate: tbs,
222            signature_algorithm: AlgorithmIdentifierOwned {
223                oid: sha256_ecdsa,
224                parameters: None,
225            },
226            signature: BitString::from_bytes(&[0u8; 64]).unwrap(),
227        };
228
229        cert.to_der().expect("cert encode failed")
230    }
231
232    #[test]
233    fn rotation_check_ok() {
234        let not_before = der::DateTime::new(2026, 1, 1, 0, 0, 0).unwrap();
235        let not_after = der::DateTime::new(2027, 6, 1, 0, 0, 0).unwrap();
236        let cert_der = build_test_cert(not_before, not_after);
237
238        let status = check_rotation_needed(&cert_der, 604800).unwrap();
239        assert!(
240            matches!(status, RotationStatus::Ok { .. }),
241            "cert valid until 2027 should be Ok"
242        );
243    }
244
245    #[test]
246    fn rotation_check_expired() {
247        let not_before = der::DateTime::new(2020, 1, 1, 0, 0, 0).unwrap();
248        let not_after = der::DateTime::new(2021, 1, 1, 0, 0, 0).unwrap();
249        let cert_der = build_test_cert(not_before, not_after);
250
251        let status = check_rotation_needed(&cert_der, 604800).unwrap();
252        assert_eq!(status, RotationStatus::Expired);
253    }
254
255    #[test]
256    fn rotation_check_renew_soon() {
257        let not_before = der::DateTime::new(2026, 1, 1, 0, 0, 0).unwrap();
258        let not_after = der::DateTime::new(2030, 1, 1, 0, 0, 0).unwrap();
259        let cert_der = build_test_cert(not_before, not_after);
260
261        // Threshold of 10 years — cert expires within threshold so triggers RenewSoon
262        let ten_years_secs = 365 * 24 * 3600 * 10;
263        let status = check_rotation_needed(&cert_der, ten_years_secs).unwrap();
264        assert!(
265            matches!(status, RotationStatus::RenewSoon { .. }),
266            "cert within huge threshold should be RenewSoon, got {status:?}"
267        );
268    }
269}
270
271#[cfg(test)]
272mod regression_tests {
273    use super::*;
274    use base64::Engine;
275
276    #[test]
277    fn pem_and_der_have_identical_rotation_status_and_invalid_cert_fails() {
278        let key = crate::SealKey::EcdsaP256(crate::demo_ecdsa_p256_key());
279        let cert = crate::generate_seal_cert_for_key(&key).unwrap();
280        let encoded = base64::engine::general_purpose::STANDARD.encode(&cert);
281        let lines = encoded
282            .as_bytes()
283            .chunks(64)
284            .map(|line| std::str::from_utf8(line).unwrap())
285            .collect::<Vec<_>>()
286            .join("\n");
287        let pem = format!("-----BEGIN CERTIFICATE-----\n{lines}\n-----END CERTIFICATE-----\n");
288        assert_eq!(
289            check_rotation_needed(&cert, u64::MAX).unwrap(),
290            check_rotation_needed(pem.as_bytes(), u64::MAX).unwrap()
291        );
292        assert!(check_rotation_needed(b"broken", 3600).is_err());
293    }
294}