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 pub archive_cutoff_secs: u64,
31 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 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 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 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 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#[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
437fn 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 if config.archive_cutoff_secs > 0 {
504 if let Some(¬_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 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
653fn 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); entries.insert(vec![100u8], CertificateStatus::Good); entries.insert(vec![0x01, 0x00], CertificateStatus::Good); let mut not_after = BTreeMap::new();
802 not_after.insert(vec![42u8], now - 100_000);
803 not_after.insert(vec![0x01, 0x00], now); 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, 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 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, ..Default::default() };
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 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 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 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 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 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 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}