Skip to main content

hoike_sign/
generate.rs

1use der::asn1::{Null, OctetString};
2use der::{Decode, Encode};
3use sha1::Sha1;
4use sha2::{Digest, Sha256};
5use signature::Signer;
6use spki::{AlgorithmIdentifierOwned, DynSignatureAlgorithmIdentifier, SignatureBitStringEncoding};
7use x509_ocsp::builder::OcspResponseBuilder;
8use x509_ocsp::{CertId, CertStatus, OcspGeneralizedTime, ResponderId, SingleResponse};
9
10use ahu::{
11    BundleBuilder, BundleType, Completeness, Continuity, Integrity, Manifest,
12    ResponderId as AhuResponderId, ResponderIdType, Window,
13};
14
15use crate::error::{Result, SignError};
16use crate::source::{CaIdentity, CertificateStatus, StatusSnapshot};
17
18#[derive(Debug, Clone)]
19pub struct GenerationConfig {
20    pub producer_id: String,
21    pub epoch: u64,
22    pub validity_secs: u64,
23    pub jitter_secs: u64,
24    pub certid_compat: CertIdCompat,
25    pub completeness: Completeness,
26    /// Prune entries for certificates that expired more than this many seconds
27    /// ago. 0 disables pruning. Only effective when the source supplies each
28    /// certificate's `notAfter` (389 DS syncrepl); a no-op for CRL sources,
29    /// which carry no per-certificate expiry.
30    pub archive_cutoff_secs: u64,
31    /// Number of SingleResponse elements per signed BasicOCSPResponse.
32    /// 1 = one signature per certificate (default).
33    /// >1 = batch N certificates under one signature, amortizing signature cost.
34    pub bucket_size: usize,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CertIdCompat {
39    Sha256Only,
40    Sha1Only,
41    Dual,
42}
43
44impl CertIdCompat {
45    /// Parse the config/CLI string form (`dual` | `sha256` | `sha1`).
46    /// Shared by the `hoike sign` CLI and the signer orchestration so the
47    /// accepted values never drift between them.
48    pub fn parse(s: &str) -> std::result::Result<Self, String> {
49        match s {
50            "dual" => Ok(CertIdCompat::Dual),
51            "sha256" => Ok(CertIdCompat::Sha256Only),
52            "sha1" => Ok(CertIdCompat::Sha1Only),
53            other => Err(format!(
54                "unknown certid_compat '{other}' (expected: dual, sha256, sha1)"
55            )),
56        }
57    }
58}
59
60impl Default for GenerationConfig {
61    fn default() -> Self {
62        GenerationConfig {
63            producer_id: "hoike-signer".into(),
64            epoch: 1,
65            validity_secs: 86400,
66            jitter_secs: 7200,
67            certid_compat: CertIdCompat::Dual,
68            completeness: Completeness::Partial,
69            archive_cutoff_secs: 0,
70            bucket_size: 1,
71        }
72    }
73}
74
75pub fn produce_bundle<S, Sig>(
76    ca: &CaIdentity,
77    snapshot: &StatusSnapshot,
78    config: &GenerationConfig,
79    signer: &mut S,
80    seal_fn: impl FnOnce(&[u8]) -> Result<Vec<u8>>,
81    responder_cert_der: Option<&[u8]>,
82) -> Result<Vec<u8>>
83where
84    S: Signer<Sig> + DynSignatureAlgorithmIdentifier,
85    Sig: SignatureBitStringEncoding,
86{
87    let now = snapshot.this_update;
88    let source_end = snapshot.validate_at(crate::source::unix_now()?)?;
89    let next_update_base = now
90        .checked_add(config.validity_secs)
91        .ok_or_else(|| SignError::Config("validity overflows".into()))?
92        .min(source_end);
93    if next_update_base <= crate::source::unix_now()? {
94        return Err(SignError::Config(
95            "generated response would already be expired".into(),
96        ));
97    }
98    let produced_at = ocsp_time(crate::source::unix_now()?)?;
99
100    let issuer_name_hash_sha256 = Sha256::digest(&ca.issuer_name_der);
101    let issuer_key_hash_sha256 = Sha256::digest(&ca.issuer_key_bytes);
102    let issuer_name_hash_sha1 = Sha1::digest(&ca.issuer_name_der);
103    let issuer_key_hash_sha1 = Sha1::digest(&ca.issuer_key_bytes);
104
105    // RFC 6960: KeyHash = SHA-1 of the responder's public key.
106    // When delegated (responder_cert provided), hash the cert's SPKI.
107    // When CA-direct, hash the CA's key bytes.
108    let responder_key_hash = if let Some(cert_der) = responder_cert_der {
109        extract_spki_key_hash(cert_der)?
110    } else {
111        Sha1::digest(&ca.issuer_key_bytes).to_vec()
112    };
113    let responder_id =
114        ResponderId::ByKey(OctetString::new(responder_key_hash.clone()).map_err(SignError::Der)?);
115
116    let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
117    let sha1_oid = const_oid::ObjectIdentifier::new_unwrap("1.3.14.3.2.26");
118
119    let responder_chain = responder_cert_der.map(|cert| vec![cert.to_vec()]);
120
121    let mut ca_scopes = vec![ahu::CaScope {
122        hash_algorithm: sha256_oid.as_bytes().to_vec(),
123        issuer_name_hash: issuer_name_hash_sha256.to_vec(),
124        issuer_key_hash: issuer_key_hash_sha256.to_vec(),
125        epoch: config.epoch,
126        responder_id: AhuResponderId {
127            id_type: ResponderIdType::ByKey,
128            value: responder_key_hash.to_vec(),
129        },
130        responder_chain: responder_chain.clone(),
131        signature_algorithm: vec![],
132        completeness: config.completeness,
133    }];
134
135    // Dual and Sha1Only modes produce SHA-1 CertID entries; register a SHA-1
136    // ca_scope so the router can route requests with SHA-1 issuer hashes.
137    if matches!(
138        config.certid_compat,
139        CertIdCompat::Dual | CertIdCompat::Sha1Only
140    ) {
141        ca_scopes.push(ahu::CaScope {
142            hash_algorithm: sha1_oid.as_bytes().to_vec(),
143            issuer_name_hash: issuer_name_hash_sha1.to_vec(),
144            issuer_key_hash: issuer_key_hash_sha1.to_vec(),
145            epoch: config.epoch,
146            responder_id: AhuResponderId {
147                id_type: ResponderIdType::ByKey,
148                value: responder_key_hash.to_vec(),
149            },
150            responder_chain: responder_chain.clone(),
151            signature_algorithm: vec![],
152            completeness: config.completeness,
153        });
154    }
155
156    // For Sha256Only, remove the SHA-256 scope if only SHA-1 is wanted —
157    // but Sha256Only should keep just the SHA-256 scope (already the default).
158
159    let manifest = Manifest {
160        format_version: 1,
161        bundle_id: uuid::Uuid::nil(),
162        producer_id: config.producer_id.clone(),
163        created_at: crate::source::unix_now()?,
164        bundle_type: BundleType::Full,
165        ca_scopes,
166        window: Window {
167            produced_at: crate::source::unix_now()?,
168            this_update_min: now,
169            next_update_min: next_update_base,
170            next_update_max: next_update_base
171                .saturating_add(config.jitter_secs)
172                .min(source_end),
173        },
174        integrity: Integrity {
175            index_digest: [0; 32],
176            data_digest: [0; 32],
177        },
178        entry_count: 0,
179        continuity: Continuity {
180            prev_manifest_digest: None,
181            base_manifest_digest: None,
182            chain_length: 0,
183        },
184        shard: None,
185        compression: None,
186        extensions: None,
187    };
188
189    let mut builder = BundleBuilder::new(manifest);
190    let this_update = ocsp_time(now)?;
191
192    let prepared = prepare_entries(
193        snapshot,
194        config,
195        &sha256_oid,
196        &sha1_oid,
197        &issuer_name_hash_sha256,
198        &issuer_key_hash_sha256,
199        &issuer_name_hash_sha1,
200        &issuer_key_hash_sha1,
201        this_update,
202        next_update_base,
203    )?;
204
205    sign_and_add_entries(
206        &mut builder,
207        &prepared,
208        config.bucket_size,
209        signer,
210        &responder_id,
211        responder_cert_der,
212        produced_at,
213        0,
214    )?;
215
216    builder
217        .build(|manifest_bytes| {
218            seal_fn(manifest_bytes).map_err(|e| ahu::AhuError::Write(e.to_string()))
219        })
220        .map_err(SignError::Bundle)
221}
222
223/// Produce a dual-algorithm bundle containing both classical and post-quantum
224/// signed responses for every serial, indexed under the same entry keys with
225/// different discriminators.
226#[allow(clippy::too_many_arguments)]
227pub fn produce_dual_bundle<S1, Sig1, S2, Sig2>(
228    ca: &CaIdentity,
229    snapshot: &StatusSnapshot,
230    config: &GenerationConfig,
231    signer_classical: &mut S1,
232    signer_pq: &mut S2,
233    disc_pq: u16,
234    seal_fn: impl FnOnce(&[u8]) -> Result<Vec<u8>>,
235    responder_cert_classical: Option<&[u8]>,
236    responder_cert_pq: Option<&[u8]>,
237) -> Result<Vec<u8>>
238where
239    S1: Signer<Sig1> + DynSignatureAlgorithmIdentifier,
240    Sig1: SignatureBitStringEncoding,
241    S2: Signer<Sig2> + DynSignatureAlgorithmIdentifier,
242    Sig2: SignatureBitStringEncoding,
243{
244    let now = snapshot.this_update;
245    let source_end = snapshot.validate_at(crate::source::unix_now()?)?;
246    let next_update_base = now
247        .checked_add(config.validity_secs)
248        .ok_or_else(|| SignError::Config("validity overflows".into()))?
249        .min(source_end);
250    if next_update_base <= crate::source::unix_now()? {
251        return Err(SignError::Config(
252            "generated response would already be expired".into(),
253        ));
254    }
255    let produced_at = ocsp_time(crate::source::unix_now()?)?;
256
257    let issuer_name_hash_sha256 = Sha256::digest(&ca.issuer_name_der);
258    let issuer_key_hash_sha256 = Sha256::digest(&ca.issuer_key_bytes);
259    let issuer_name_hash_sha1 = Sha1::digest(&ca.issuer_name_der);
260    let issuer_key_hash_sha1 = Sha1::digest(&ca.issuer_key_bytes);
261
262    let responder_key_hash_classical = if let Some(cert_der) = responder_cert_classical {
263        extract_spki_key_hash(cert_der)?
264    } else {
265        Sha1::digest(&ca.issuer_key_bytes).to_vec()
266    };
267
268    let responder_key_hash_pq = if let Some(cert_der) = responder_cert_pq {
269        extract_spki_key_hash(cert_der)?
270    } else {
271        Sha1::digest(&ca.issuer_key_bytes).to_vec()
272    };
273
274    let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
275    let sha1_oid = const_oid::ObjectIdentifier::new_unwrap("1.3.14.3.2.26");
276
277    let responder_chain_classical = responder_cert_classical.map(|cert| vec![cert.to_vec()]);
278    let responder_chain_pq = responder_cert_pq.map(|cert| vec![cert.to_vec()]);
279
280    let mut ca_scopes = vec![
281        ahu::CaScope {
282            hash_algorithm: sha256_oid.as_bytes().to_vec(),
283            issuer_name_hash: issuer_name_hash_sha256.to_vec(),
284            issuer_key_hash: issuer_key_hash_sha256.to_vec(),
285            epoch: config.epoch,
286            responder_id: AhuResponderId {
287                id_type: ResponderIdType::ByKey,
288                value: responder_key_hash_classical.to_vec(),
289            },
290            responder_chain: responder_chain_classical.clone(),
291            signature_algorithm: vec![],
292            completeness: config.completeness,
293        },
294        ahu::CaScope {
295            hash_algorithm: sha256_oid.as_bytes().to_vec(),
296            issuer_name_hash: issuer_name_hash_sha256.to_vec(),
297            issuer_key_hash: issuer_key_hash_sha256.to_vec(),
298            epoch: config.epoch,
299            responder_id: AhuResponderId {
300                id_type: ResponderIdType::ByKey,
301                value: responder_key_hash_pq.to_vec(),
302            },
303            responder_chain: responder_chain_pq.clone(),
304            signature_algorithm: vec![],
305            completeness: config.completeness,
306        },
307    ];
308
309    if matches!(
310        config.certid_compat,
311        CertIdCompat::Dual | CertIdCompat::Sha1Only
312    ) {
313        ca_scopes.push(ahu::CaScope {
314            hash_algorithm: sha1_oid.as_bytes().to_vec(),
315            issuer_name_hash: issuer_name_hash_sha1.to_vec(),
316            issuer_key_hash: issuer_key_hash_sha1.to_vec(),
317            epoch: config.epoch,
318            responder_id: AhuResponderId {
319                id_type: ResponderIdType::ByKey,
320                value: responder_key_hash_classical.to_vec(),
321            },
322            responder_chain: responder_chain_classical,
323            signature_algorithm: vec![],
324            completeness: config.completeness,
325        });
326        ca_scopes.push(ahu::CaScope {
327            hash_algorithm: sha1_oid.as_bytes().to_vec(),
328            issuer_name_hash: issuer_name_hash_sha1.to_vec(),
329            issuer_key_hash: issuer_key_hash_sha1.to_vec(),
330            epoch: config.epoch,
331            responder_id: AhuResponderId {
332                id_type: ResponderIdType::ByKey,
333                value: responder_key_hash_pq.to_vec(),
334            },
335            responder_chain: responder_chain_pq,
336            signature_algorithm: vec![],
337            completeness: config.completeness,
338        });
339    }
340
341    let manifest = Manifest {
342        format_version: 1,
343        bundle_id: uuid::Uuid::nil(),
344        producer_id: config.producer_id.clone(),
345        created_at: crate::source::unix_now()?,
346        bundle_type: BundleType::Full,
347        ca_scopes,
348        window: Window {
349            produced_at: crate::source::unix_now()?,
350            this_update_min: now,
351            next_update_min: next_update_base,
352            next_update_max: next_update_base
353                .saturating_add(config.jitter_secs)
354                .min(source_end),
355        },
356        integrity: Integrity {
357            index_digest: [0; 32],
358            data_digest: [0; 32],
359        },
360        entry_count: 0,
361        continuity: Continuity {
362            prev_manifest_digest: None,
363            base_manifest_digest: None,
364            chain_length: 0,
365        },
366        shard: None,
367        compression: None,
368        extensions: None,
369    };
370
371    let mut builder = BundleBuilder::new(manifest);
372    let this_update = ocsp_time(now)?;
373
374    let prepared = prepare_entries(
375        snapshot,
376        config,
377        &sha256_oid,
378        &sha1_oid,
379        &issuer_name_hash_sha256,
380        &issuer_key_hash_sha256,
381        &issuer_name_hash_sha1,
382        &issuer_key_hash_sha1,
383        this_update,
384        next_update_base,
385    )?;
386
387    let responder_id_classical =
388        ResponderId::ByKey(OctetString::new(responder_key_hash_classical).map_err(SignError::Der)?);
389    let responder_id_pq =
390        ResponderId::ByKey(OctetString::new(responder_key_hash_pq).map_err(SignError::Der)?);
391
392    sign_and_add_entries(
393        &mut builder,
394        &prepared,
395        config.bucket_size,
396        signer_classical,
397        &responder_id_classical,
398        responder_cert_classical,
399        produced_at,
400        0,
401    )?;
402    sign_and_add_entries(
403        &mut builder,
404        &prepared,
405        config.bucket_size,
406        signer_pq,
407        &responder_id_pq,
408        responder_cert_pq,
409        produced_at,
410        disc_pq,
411    )?;
412
413    builder
414        .build(|manifest_bytes| {
415            seal_fn(manifest_bytes).map_err(|e| ahu::AhuError::Write(e.to_string()))
416        })
417        .map_err(SignError::Bundle)
418}
419
420fn build_certid(
421    hash_oid: const_oid::ObjectIdentifier,
422    name_hash: &[u8],
423    key_hash: &[u8],
424    serial: x509_cert::serial_number::SerialNumber,
425) -> Result<CertId> {
426    Ok(CertId {
427        hash_algorithm: AlgorithmIdentifierOwned {
428            oid: hash_oid,
429            parameters: Some(Null.into()),
430        },
431        issuer_name_hash: OctetString::new(name_hash.to_vec()).map_err(SignError::Der)?,
432        issuer_key_hash: OctetString::new(key_hash.to_vec()).map_err(SignError::Der)?,
433        serial_number: serial,
434    })
435}
436
437/// Add multiple entry keys all pointing to the same response blob.
438/// Pairs consecutive keys via add_dual_entry so ALIAS dedup stores the
439/// data once. An odd key out gets a regular add_entry (one extra data copy
440/// per bucket — negligible vs the signature amortization savings).
441fn add_shared_entries(
442    builder: &mut BundleBuilder,
443    keys: &[[u8; 32]],
444    response_der: Vec<u8>,
445    discriminator: u16,
446) {
447    match keys.len() {
448        0 => {}
449        1 => {
450            builder.add_entry_with_discriminator(keys[0], discriminator, response_der);
451        }
452        2 => {
453            builder.add_dual_entry_with_discriminator(
454                keys[0],
455                keys[1],
456                response_der,
457                discriminator,
458            );
459        }
460        _ => {
461            let mut i = 0;
462            while i + 1 < keys.len() {
463                builder.add_dual_entry_with_discriminator(
464                    keys[i],
465                    keys[i + 1],
466                    response_der.clone(),
467                    discriminator,
468                );
469                i += 2;
470            }
471            if i < keys.len() {
472                builder.add_entry_with_discriminator(keys[i], discriminator, response_der);
473            }
474        }
475    }
476}
477
478struct PreparedEntry {
479    entry_keys: Vec<[u8; 32]>,
480    single_responses: Vec<SingleResponse>,
481}
482
483#[allow(clippy::too_many_arguments)]
484fn prepare_entries(
485    snapshot: &StatusSnapshot,
486    config: &GenerationConfig,
487    sha256_oid: &const_oid::ObjectIdentifier,
488    sha1_oid: &const_oid::ObjectIdentifier,
489    issuer_name_hash_sha256: &[u8],
490    issuer_key_hash_sha256: &[u8],
491    issuer_name_hash_sha1: &[u8],
492    issuer_key_hash_sha1: &[u8],
493    this_update: OcspGeneralizedTime,
494    next_update_base: u64,
495) -> Result<Vec<PreparedEntry>> {
496    let mut prepared = Vec::with_capacity(snapshot.entries.len());
497    for (serial, status) in &snapshot.entries {
498        // archive_cutoff: drop long-expired certificates so the bundle does not
499        // grow without bound. Only entries with a KNOWN notAfter are eligible —
500        // an entry whose expiry is unknown (every CRL-sourced entry) is never
501        // pruned, so a still-revoked certificate can never silently degrade to
502        // "unknown". This makes the feature a safe no-op for CRL sources.
503        if config.archive_cutoff_secs > 0 {
504            if let Some(&not_after) = snapshot.not_after.get(serial) {
505                if not_after.saturating_add(config.archive_cutoff_secs) < snapshot.this_update {
506                    continue;
507                }
508            }
509        }
510
511        let cert_status = match status {
512            CertificateStatus::Good => CertStatus::good(),
513            CertificateStatus::Revoked {
514                revocation_time,
515                reason,
516            } => {
517                let revoked_info = x509_ocsp::RevokedInfo {
518                    revocation_time: ocsp_time(*revocation_time)?,
519                    revocation_reason: *reason,
520                };
521                CertStatus::revoked(revoked_info)
522            }
523        };
524
525        let serial_number =
526            x509_cert::serial_number::SerialNumber::new(serial).map_err(SignError::Der)?;
527
528        let entry_key_jitter = {
529            let mut h = Sha256::new();
530            h.update(serial);
531            let d: [u8; 32] = h.finalize().into();
532            let frac = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) as u64;
533            ((frac as u128 * config.jitter_secs as u128) / u32::MAX as u128) as u64
534        };
535        let next_update_time = next_update_base.saturating_add(entry_key_jitter).min(
536            snapshot
537                .next_update
538                .ok_or_else(|| SignError::Config("missing source expiry".into()))?,
539        );
540        let next_update = ocsp_time(next_update_time)?;
541
542        match config.certid_compat {
543            CertIdCompat::Sha256Only => {
544                let cert_id = build_certid(
545                    *sha256_oid,
546                    issuer_name_hash_sha256,
547                    issuer_key_hash_sha256,
548                    serial_number,
549                )?;
550                let certid_der = cert_id.to_der().map_err(SignError::Der)?;
551                let entry_key: [u8; 32] = Sha256::digest(&certid_der).into();
552                let single = SingleResponse::new(cert_id, cert_status, this_update)
553                    .with_next_update(next_update);
554                prepared.push(PreparedEntry {
555                    entry_keys: vec![entry_key],
556                    single_responses: vec![single],
557                });
558            }
559            CertIdCompat::Sha1Only => {
560                let cert_id = build_certid(
561                    *sha1_oid,
562                    issuer_name_hash_sha1,
563                    issuer_key_hash_sha1,
564                    serial_number,
565                )?;
566                let certid_der = cert_id.to_der().map_err(SignError::Der)?;
567                let entry_key: [u8; 32] = Sha256::digest(&certid_der).into();
568                let single = SingleResponse::new(cert_id, cert_status, this_update)
569                    .with_next_update(next_update);
570                prepared.push(PreparedEntry {
571                    entry_keys: vec![entry_key],
572                    single_responses: vec![single],
573                });
574            }
575            CertIdCompat::Dual => {
576                let cert_id_sha256 = build_certid(
577                    *sha256_oid,
578                    issuer_name_hash_sha256,
579                    issuer_key_hash_sha256,
580                    serial_number.clone(),
581                )?;
582                let cert_id_sha1 = build_certid(
583                    *sha1_oid,
584                    issuer_name_hash_sha1,
585                    issuer_key_hash_sha1,
586                    serial_number,
587                )?;
588                let ek256: [u8; 32] =
589                    Sha256::digest(&cert_id_sha256.to_der().map_err(SignError::Der)?).into();
590                let ek1: [u8; 32] =
591                    Sha256::digest(&cert_id_sha1.to_der().map_err(SignError::Der)?).into();
592
593                let single_sha256 = SingleResponse::new(cert_id_sha256, cert_status, this_update)
594                    .with_next_update(next_update);
595                let single_sha1 = SingleResponse::new(cert_id_sha1, cert_status, this_update)
596                    .with_next_update(next_update);
597
598                prepared.push(PreparedEntry {
599                    entry_keys: vec![ek256, ek1],
600                    single_responses: vec![single_sha256, single_sha1],
601                });
602            }
603        }
604    }
605    Ok(prepared)
606}
607
608#[allow(clippy::too_many_arguments)]
609fn sign_and_add_entries<S, Sig>(
610    builder: &mut BundleBuilder,
611    prepared: &[PreparedEntry],
612    bucket_size: usize,
613    signer: &mut S,
614    responder_id: &ResponderId,
615    responder_cert_der: Option<&[u8]>,
616    produced_at: OcspGeneralizedTime,
617    discriminator: u16,
618) -> Result<()>
619where
620    S: Signer<Sig> + DynSignatureAlgorithmIdentifier,
621    Sig: SignatureBitStringEncoding,
622{
623    let bucket_size = bucket_size.max(1);
624
625    // Parse the responder certificate once, outside the loop, to avoid
626    // redundant DER decoding on every bucket iteration.
627    let parsed_cert = responder_cert_der
628        .map(|c| x509_cert::Certificate::from_der(c).map_err(SignError::Der))
629        .transpose()?;
630
631    for bucket in prepared.chunks(bucket_size) {
632        let mut response_builder = OcspResponseBuilder::new(responder_id.clone());
633        let mut all_keys: Vec<[u8; 32]> = Vec::new();
634
635        for entry in bucket {
636            for single in &entry.single_responses {
637                response_builder = response_builder.with_single_response(single.clone());
638            }
639            all_keys.extend_from_slice(&entry.entry_keys);
640        }
641
642        let certs = parsed_cert.as_ref().map(|c| vec![c.clone()]);
643        let ocsp_response = response_builder
644            .sign(signer, certs, produced_at)
645            .map_err(SignError::from)?;
646        let response_der = ocsp_response.to_der().map_err(SignError::Der)?;
647
648        add_shared_entries(builder, &all_keys, response_der, discriminator);
649    }
650    Ok(())
651}
652
653/// Extract SHA-1 hash of the subject public key from a DER-encoded certificate.
654/// This is the ResponderID KeyHash per RFC 6960: SHA-1 of the BIT STRING
655/// subjectPublicKey value (excluding tag and length).
656fn extract_spki_key_hash(cert_der: &[u8]) -> Result<Vec<u8>> {
657    use der::Decode;
658    let cert = x509_cert::Certificate::from_der(cert_der)
659        .map_err(|e| SignError::KeyLoad(format!("parse responder cert for SPKI: {e}")))?;
660    let spki = &cert.tbs_certificate.subject_public_key_info;
661    let key_bytes = spki.subject_public_key.raw_bytes();
662    Ok(Sha1::digest(key_bytes).to_vec())
663}
664
665pub fn ocsp_time(epoch_secs: u64) -> Result<OcspGeneralizedTime> {
666    let dt = epoch_to_datetime(epoch_secs)?;
667    Ok(OcspGeneralizedTime::from(dt))
668}
669
670pub fn datetime_to_epoch(dt: der::DateTime) -> u64 {
671    let year = dt.year() as u64;
672    let month = dt.month() as u64;
673    let day = dt.day() as u64;
674    let hour = dt.hour() as u64;
675    let minutes = dt.minutes() as u64;
676    let seconds = dt.seconds() as u64;
677
678    let mut days: u64 = 0;
679    for y in 1970..year {
680        days += if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
681            366
682        } else {
683            365
684        };
685    }
686    let mdays = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
687    for m in 1..month {
688        days += mdays[m as usize] as u64;
689        if m == 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) {
690            days += 1;
691        }
692    }
693    days += day - 1;
694    days * 86400 + hour * 3600 + minutes * 60 + seconds
695}
696
697pub fn epoch_to_datetime(secs: u64) -> Result<der::DateTime> {
698    let days = secs / 86400;
699    let time_of_day = secs % 86400;
700    let hours = (time_of_day / 3600) as u8;
701    let minutes = ((time_of_day % 3600) / 60) as u8;
702    let seconds = (time_of_day % 60) as u8;
703
704    let (year, month, day) = days_to_ymd(days);
705    der::DateTime::new(year as u16, month as u8, day as u8, hours, minutes, seconds)
706        .map_err(SignError::Der)
707}
708
709fn days_to_ymd(days: u64) -> (u64, u64, u64) {
710    let z = days + 719468;
711    let era = z / 146097;
712    let doe = z - era * 146097;
713    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
714    let y = yoe + era * 400;
715    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
716    let mp = (5 * doy + 2) / 153;
717    let d = doy - (153 * mp + 2) / 5 + 1;
718    let m = if mp < 10 { mp + 3 } else { mp - 9 };
719    let y = if m <= 2 { y + 1 } else { y };
720    (y, m, d)
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use crate::source::CertificateStatus;
727    use der::{Decode, Encode};
728    use p256::ecdsa::SigningKey;
729    use std::collections::BTreeMap;
730    use x509_cert::ext::pkix::CrlReason;
731
732    fn test_signing_key() -> SigningKey {
733        let secret = [1u8; 32];
734        SigningKey::from_bytes((&secret).into()).unwrap()
735    }
736
737    fn test_snapshot() -> StatusSnapshot {
738        let mut entries = BTreeMap::new();
739        entries.insert(vec![42u8], CertificateStatus::Good);
740        entries.insert(
741            vec![100u8],
742            CertificateStatus::Revoked {
743                revocation_time: 1700000000,
744                reason: Some(CrlReason::KeyCompromise),
745            },
746        );
747        entries.insert(vec![0x01, 0x00], CertificateStatus::Good);
748
749        StatusSnapshot {
750            entries,
751            this_update: crate::source::unix_now().unwrap(),
752            next_update: Some(crate::source::unix_now().unwrap() + 86400),
753            ..Default::default()
754        }
755    }
756
757    fn test_ca() -> CaIdentity {
758        CaIdentity {
759            label: "test-ca".into(),
760            issuer_name_der: b"CN=Test CA,O=Hoike Test".to_vec(),
761            issuer_key_bytes: b"test-ca-public-key-bytes".to_vec(),
762        }
763    }
764
765    #[test]
766    fn produce_sha256_only_bundle() {
767        let ca = test_ca();
768        let snapshot = test_snapshot();
769        let config = GenerationConfig {
770            certid_compat: CertIdCompat::Sha256Only,
771            ..Default::default()
772        };
773        let mut key = test_signing_key();
774
775        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
776            &ca,
777            &snapshot,
778            &config,
779            &mut key,
780            |m| Ok(Sha256::digest(m).to_vec()),
781            None,
782        )
783        .unwrap();
784
785        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
786        let result = ahu::verify_structure(&bundle).unwrap();
787        assert!(result.index_digest_ok);
788        assert!(result.data_digest_ok);
789        assert!(result.sort_order_ok);
790        assert_eq!(bundle.manifest.entry_count, 3);
791    }
792
793    #[test]
794    fn archive_cutoff_prunes_only_known_expired_entries() {
795        let ca = test_ca();
796        let now = crate::source::unix_now().unwrap();
797        let mut entries = BTreeMap::new();
798        entries.insert(vec![42u8], CertificateStatus::Good); // long expired -> pruned
799        entries.insert(vec![100u8], CertificateStatus::Good); // no notAfter -> kept
800        entries.insert(vec![0x01, 0x00], CertificateStatus::Good); // recently expired -> kept
801        let mut not_after = BTreeMap::new();
802        not_after.insert(vec![42u8], now - 100_000);
803        not_after.insert(vec![0x01, 0x00], now); // within the cutoff window
804        let snapshot = StatusSnapshot {
805            entries,
806            this_update: now,
807            next_update: Some(now + 86400),
808            not_after,
809        };
810        let config = GenerationConfig {
811            certid_compat: CertIdCompat::Sha256Only, // one index entry per certificate
812            archive_cutoff_secs: 10,
813            ..Default::default()
814        };
815        let mut key = test_signing_key();
816
817        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
818            &ca,
819            &snapshot,
820            &config,
821            &mut key,
822            |m| Ok(Sha256::digest(m).to_vec()),
823            None,
824        )
825        .unwrap();
826
827        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
828        // Serial 42 (expired 100_000s ago, cutoff 10s) is dropped; the unknown-expiry
829        // entry and the recently-expired entry both survive.
830        assert_eq!(bundle.manifest.entry_count, 2);
831    }
832
833    #[test]
834    fn archive_cutoff_disabled_keeps_all_entries() {
835        let ca = test_ca();
836        let now = crate::source::unix_now().unwrap();
837        let mut entries = BTreeMap::new();
838        entries.insert(vec![42u8], CertificateStatus::Good);
839        let mut not_after = BTreeMap::new();
840        not_after.insert(vec![42u8], now - 100_000);
841        let snapshot = StatusSnapshot {
842            entries,
843            this_update: now,
844            next_update: Some(now + 86400),
845            not_after,
846        };
847        let config = GenerationConfig {
848            certid_compat: CertIdCompat::Sha256Only, // one index entry per certificate
849            ..Default::default()                     // archive_cutoff_secs = 0
850        };
851        let mut key = test_signing_key();
852        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
853            &ca,
854            &snapshot,
855            &config,
856            &mut key,
857            |m| Ok(Sha256::digest(m).to_vec()),
858            None,
859        )
860        .unwrap();
861        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
862        assert_eq!(bundle.manifest.entry_count, 1);
863    }
864
865    #[test]
866    fn produce_dual_certid_bundle() {
867        let ca = test_ca();
868        let snapshot = test_snapshot();
869        let config = GenerationConfig {
870            certid_compat: CertIdCompat::Dual,
871            ..Default::default()
872        };
873        let mut key = test_signing_key();
874
875        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
876            &ca,
877            &snapshot,
878            &config,
879            &mut key,
880            |m| Ok(Sha256::digest(m).to_vec()),
881            None,
882        )
883        .unwrap();
884
885        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
886        let result = ahu::verify_structure(&bundle).unwrap();
887        assert!(result.index_digest_ok);
888        assert!(result.data_digest_ok);
889        // 3 certs × 2 CertIDs each = 6 index records
890        assert_eq!(bundle.manifest.entry_count, 6);
891    }
892
893    #[test]
894    fn round_trip_lookup() {
895        let ca = test_ca();
896        let snapshot = test_snapshot();
897        let config = GenerationConfig {
898            certid_compat: CertIdCompat::Sha256Only,
899            ..Default::default()
900        };
901        let mut key = test_signing_key();
902
903        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
904            &ca,
905            &snapshot,
906            &config,
907            &mut key,
908            |m| Ok(Sha256::digest(m).to_vec()),
909            None,
910        )
911        .unwrap();
912
913        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
914
915        let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
916        let name_hash = Sha256::digest(&ca.issuer_name_der);
917        let key_hash = Sha256::digest(&ca.issuer_key_bytes);
918        let serial = x509_cert::serial_number::SerialNumber::new(&[42u8]).unwrap();
919        let cert_id = build_certid(sha256_oid, &name_hash, &key_hash, serial).unwrap();
920        let certid_der = cert_id.to_der().unwrap();
921        let entry_key: [u8; 32] = Sha256::digest(&certid_der).into();
922
923        let response = bundle
924            .lookup(&entry_key)
925            .expect("entry for serial 42 not found");
926        assert!(!response.is_empty());
927
928        let ocsp_resp = <x509_ocsp::OcspResponse as Decode>::from_der(response)
929            .expect("invalid OCSP response DER");
930        assert_eq!(
931            ocsp_resp.response_status,
932            x509_ocsp::OcspResponseStatus::Successful
933        );
934    }
935
936    fn large_snapshot(n: usize) -> StatusSnapshot {
937        let mut entries = BTreeMap::new();
938        for i in 0..n {
939            let serial = (i as u32 + 1).to_be_bytes().to_vec();
940            if i % 5 == 0 {
941                entries.insert(
942                    serial,
943                    CertificateStatus::Revoked {
944                        revocation_time: 1700000000,
945                        reason: Some(CrlReason::Unspecified),
946                    },
947                );
948            } else {
949                entries.insert(serial, CertificateStatus::Good);
950            }
951        }
952        StatusSnapshot {
953            entries,
954            this_update: crate::source::unix_now().unwrap(),
955            next_update: Some(crate::source::unix_now().unwrap() + 86400),
956            ..Default::default()
957        }
958    }
959
960    #[test]
961    fn batching_bucket_5_produces_correct_bundle() {
962        let ca = test_ca();
963        let snapshot = large_snapshot(10);
964        let config = GenerationConfig {
965            certid_compat: CertIdCompat::Sha256Only,
966            bucket_size: 5,
967            ..Default::default()
968        };
969        let mut key = test_signing_key();
970
971        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
972            &ca,
973            &snapshot,
974            &config,
975            &mut key,
976            |m| Ok(Sha256::digest(m).to_vec()),
977            None,
978        )
979        .unwrap();
980
981        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
982        let result = ahu::verify_structure(&bundle).unwrap();
983        assert!(result.index_digest_ok);
984        assert!(result.data_digest_ok);
985        assert!(result.sort_order_ok);
986        // 10 entries, each with its own index record
987        assert_eq!(bundle.manifest.entry_count, 10);
988    }
989
990    #[test]
991    fn batching_preserves_lookup() {
992        let ca = test_ca();
993        let snapshot = large_snapshot(20);
994        let config = GenerationConfig {
995            certid_compat: CertIdCompat::Sha256Only,
996            bucket_size: 5,
997            ..Default::default()
998        };
999        let mut key = test_signing_key();
1000
1001        let bundle_bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
1002            &ca,
1003            &snapshot,
1004            &config,
1005            &mut key,
1006            |m| Ok(Sha256::digest(m).to_vec()),
1007            None,
1008        )
1009        .unwrap();
1010
1011        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
1012
1013        let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
1014        let name_hash = Sha256::digest(&ca.issuer_name_der);
1015        let key_hash_val = Sha256::digest(&ca.issuer_key_bytes);
1016
1017        // Verify every entry can be looked up and returns valid OCSP DER
1018        for i in 0..20u32 {
1019            let serial_bytes = (i + 1).to_be_bytes().to_vec();
1020            let serial = x509_cert::serial_number::SerialNumber::new(&serial_bytes).unwrap();
1021            let cert_id = build_certid(sha256_oid, &name_hash, &key_hash_val, serial).unwrap();
1022            let certid_der = cert_id.to_der().unwrap();
1023            let entry_key: [u8; 32] = Sha256::digest(&certid_der).into();
1024
1025            let response = bundle
1026                .lookup(&entry_key)
1027                .unwrap_or_else(|| panic!("entry for serial {} not found", i + 1));
1028            assert!(!response.is_empty(), "empty response for serial {}", i + 1);
1029
1030            let ocsp_resp = <x509_ocsp::OcspResponse as Decode>::from_der(response)
1031                .unwrap_or_else(|e| panic!("invalid DER for serial {}: {e}", i + 1));
1032            assert_eq!(
1033                ocsp_resp.response_status,
1034                x509_ocsp::OcspResponseStatus::Successful
1035            );
1036        }
1037    }
1038
1039    #[test]
1040    fn batching_reduces_data_size() {
1041        let ca = test_ca();
1042        let snapshot = large_snapshot(50);
1043        let mut key = test_signing_key();
1044
1045        // Unbatched
1046        let config_1 = GenerationConfig {
1047            certid_compat: CertIdCompat::Sha256Only,
1048            bucket_size: 1,
1049            ..Default::default()
1050        };
1051        let unbatched = produce_bundle::<_, p256::ecdsa::DerSignature>(
1052            &ca,
1053            &snapshot,
1054            &config_1,
1055            &mut key,
1056            |m| Ok(Sha256::digest(m).to_vec()),
1057            None,
1058        )
1059        .unwrap();
1060
1061        // Batched with bucket_size=10
1062        let config_10 = GenerationConfig {
1063            certid_compat: CertIdCompat::Sha256Only,
1064            bucket_size: 10,
1065            ..Default::default()
1066        };
1067        let batched = produce_bundle::<_, p256::ecdsa::DerSignature>(
1068            &ca,
1069            &snapshot,
1070            &config_10,
1071            &mut key,
1072            |m| Ok(Sha256::digest(m).to_vec()),
1073            None,
1074        )
1075        .unwrap();
1076
1077        // Batched should be smaller (fewer signatures)
1078        assert!(
1079            batched.len() < unbatched.len(),
1080            "batched ({}) should be smaller than unbatched ({})",
1081            batched.len(),
1082            unbatched.len()
1083        );
1084    }
1085
1086    #[test]
1087    fn produce_dual_bundle_round_trip() {
1088        let ca = test_ca();
1089        let snapshot = large_snapshot(5);
1090        let config = GenerationConfig {
1091            certid_compat: CertIdCompat::Sha256Only,
1092            ..Default::default()
1093        };
1094
1095        let mut ecdsa_signer = test_signing_key();
1096        let mut ml_dsa_signer = crate::ml_dsa_87_signer(&[42u8; 32]);
1097
1098        let bundle_bytes =
1099            produce_dual_bundle::<_, p256::ecdsa::DerSignature, _, crate::MlDsaSignatureBytes>(
1100                &ca,
1101                &snapshot,
1102                &config,
1103                &mut ecdsa_signer,
1104                &mut ml_dsa_signer,
1105                ahu::ALG_DISC_ML_DSA_87,
1106                |m| Ok(Sha256::digest(m).to_vec()),
1107                None,
1108                None,
1109            )
1110            .unwrap();
1111
1112        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
1113        let result = ahu::verify_structure(&bundle).unwrap();
1114        assert!(result.index_digest_ok);
1115        assert!(result.data_digest_ok);
1116        assert!(result.sort_order_ok);
1117
1118        assert_eq!(bundle.index.len(), 10, "5 serials × 2 algorithms");
1119    }
1120
1121    #[test]
1122    fn dual_bundle_lookup_by_discriminator() {
1123        let ca = test_ca();
1124        let snapshot = large_snapshot(3);
1125        let config = GenerationConfig {
1126            certid_compat: CertIdCompat::Sha256Only,
1127            ..Default::default()
1128        };
1129
1130        let mut ecdsa_signer = test_signing_key();
1131        let mut ml_dsa_signer = crate::ml_dsa_87_signer(&[7u8; 32]);
1132
1133        let bundle_bytes =
1134            produce_dual_bundle::<_, p256::ecdsa::DerSignature, _, crate::MlDsaSignatureBytes>(
1135                &ca,
1136                &snapshot,
1137                &config,
1138                &mut ecdsa_signer,
1139                &mut ml_dsa_signer,
1140                ahu::ALG_DISC_ML_DSA_87,
1141                |m| Ok(Sha256::digest(m).to_vec()),
1142                None,
1143                None,
1144            )
1145            .unwrap();
1146
1147        let bundle = ahu::Bundle::from_bytes(&bundle_bytes).unwrap();
1148
1149        let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
1150        let name_hash = Sha256::digest(&ca.issuer_name_der);
1151        let key_hash_val = Sha256::digest(&ca.issuer_key_bytes);
1152
1153        let serial = x509_cert::serial_number::SerialNumber::new(&[1u8]).unwrap();
1154        let cert_id = build_certid(sha256_oid, &name_hash, &key_hash_val, serial).unwrap();
1155        let certid_der = cert_id.to_der().unwrap();
1156        let entry_key: [u8; 32] = Sha256::digest(&certid_der).into();
1157
1158        let classical = bundle.lookup(&entry_key);
1159        assert!(classical.is_some(), "disc=0 lookup should find classical");
1160
1161        let pq = bundle.lookup_preferred(&entry_key, &[ahu::ALG_DISC_ML_DSA_87]);
1162        assert!(pq.is_some(), "disc=4 lookup should find ML-DSA-87");
1163
1164        assert_ne!(
1165            classical.unwrap().len(),
1166            pq.unwrap().len(),
1167            "classical and PQ responses should differ in size"
1168        );
1169    }
1170    #[test]
1171    fn source_expiry_caps_every_signed_entry_even_with_large_jitter() {
1172        let mut snapshot = test_snapshot();
1173        let end = crate::source::unix_now().unwrap() + 30;
1174        snapshot.next_update = Some(end);
1175        let config = GenerationConfig {
1176            jitter_secs: u64::MAX,
1177            ..Default::default()
1178        };
1179        let mut key = test_signing_key();
1180        let bytes = produce_bundle::<_, p256::ecdsa::DerSignature>(
1181            &test_ca(),
1182            &snapshot,
1183            &config,
1184            &mut key,
1185            |_| Ok(vec![]),
1186            None,
1187        )
1188        .unwrap();
1189        let bundle = ahu::Bundle::from_bytes(&bytes).unwrap();
1190        assert!(bundle.manifest.window.next_update_max <= end);
1191        for entry in &bundle.index {
1192            let response = bundle.lookup(&entry.entry_key).unwrap();
1193            let response = x509_ocsp::OcspResponse::from_der(response).unwrap();
1194            let basic = x509_ocsp::BasicOcspResponse::from_der(
1195                response.response_bytes.unwrap().response.as_bytes(),
1196            )
1197            .unwrap();
1198            for single in basic.tbs_response_data.responses {
1199                assert!(single.next_update.unwrap().0.to_unix_duration().as_secs() <= end);
1200            }
1201        }
1202    }
1203
1204    #[test]
1205    fn expired_source_is_rejected_before_signing() {
1206        let mut snapshot = test_snapshot();
1207        snapshot.this_update = 1;
1208        snapshot.next_update = Some(2);
1209        let mut key = test_signing_key();
1210        assert!(
1211            produce_bundle::<_, p256::ecdsa::DerSignature>(
1212                &test_ca(),
1213                &snapshot,
1214                &GenerationConfig::default(),
1215                &mut key,
1216                |_| panic!("must not seal stale evidence"),
1217                None
1218            )
1219            .is_err()
1220        );
1221    }
1222}