1use std::collections::HashMap;
14use std::path::PathBuf;
15
16use hoike_core::config::{CaConfig, Config};
17use sha2::Digest as _;
18use tracing::{info, warn};
19
20use crate::{CaIdentity, CrlSource, GenerationConfig, RevocationSource, SealKey};
21
22pub const COMBINED_PRODUCER_ID: &str = "hoike-combined";
28
29pub type PersistentSources = HashMap<String, Box<dyn RevocationSource>>;
33
34#[derive(Debug, Clone)]
36pub struct SignedScope {
37 pub label: String,
38 pub bytes: Vec<u8>,
40 pub entry_count: usize,
41 pub epoch: u64,
43}
44
45#[allow(unused_mut)]
49#[allow(unused_variables)]
50#[cfg_attr(not(feature = "dogtag-sync"), allow(unreachable_code))]
53pub fn create_persistent_sources(
54 config: &Config,
55) -> std::result::Result<PersistentSources, String> {
56 let mut sources = PersistentSources::new();
57 for ca_config in &config.ca {
58 let source_config = match &ca_config.source {
59 Some(s) => s,
60 None => continue,
61 };
62 let source: Box<dyn RevocationSource> = match source_config {
63 #[cfg(feature = "dogtag-sync")]
64 hoike_core::config::SourceConfig::DogtagSync {
65 ldap_url,
66 base_dn,
67 bind_dn,
68 bind_password,
69 bind_password_env,
70 cookie_path,
71 filter,
72 tls,
73 ca_cert,
74 } => {
75 let password =
76 resolve_ldap_password(bind_password.as_deref(), bind_password_env.as_deref())?;
77 let cookie = cookie_path.clone().unwrap_or_else(|| {
78 config
79 .storage
80 .state_db
81 .join(format!("sync-{}.json", ca_config.label))
82 });
83 let sync_config = crate::DogtagSyncConfig {
84 ldap_url: ldap_url.clone(),
85 base_dn: base_dn.clone(),
86 bind_dn: bind_dn.clone(),
87 bind_password: password,
88 cookie_path: cookie,
89 filter: filter
90 .clone()
91 .unwrap_or_else(|| "(objectClass=certificateRecord)".into()),
92 tls: tls.clone(),
93 ca_cert: ca_cert.clone(),
94 };
95 Box::new(crate::DogtagSyncSource::new(sync_config))
96 }
97 _ => continue,
99 };
100 sources.insert(ca_config.label.clone(), source);
101 }
102 Ok(sources)
103}
104
105fn resolve_source<'a>(
109 ca_config: &CaConfig,
110 persistent_sources: &'a PersistentSources,
111 fresh_holder: &'a mut Option<Box<dyn RevocationSource>>,
112) -> std::result::Result<&'a dyn RevocationSource, String> {
113 if let Some(ps) = persistent_sources.get(&ca_config.label) {
114 return Ok(ps.as_ref());
115 }
116
117 let source_config = ca_config
118 .source
119 .as_ref()
120 .ok_or_else(|| format!("CA '{}': no revocation source configured", ca_config.label))?;
121
122 let fresh: Box<dyn RevocationSource> = match source_config {
123 hoike_core::config::SourceConfig::Crl { path, issuer_cert } => {
124 let crl_data = std::fs::read(path)
125 .map_err(|e| format!("failed to read CRL {}: {e}", path.display()))?;
126 let source = if crl_data.starts_with(b"-----BEGIN") {
127 let pem =
128 String::from_utf8(crl_data).map_err(|e| format!("CRL not valid UTF-8: {e}"))?;
129 CrlSource::from_pem(&pem).map_err(|e| format!("CRL parse: {e}"))?
130 } else {
131 CrlSource::from_der(crl_data).map_err(|e| format!("CRL parse: {e}"))?
132 };
133 let issuer_path = issuer_cert.as_ref().ok_or_else(|| {
134 format!(
135 "CA '{}': CRL source requires issuer_cert for authentication",
136 ca_config.label
137 )
138 })?;
139 let issuer =
140 std::fs::read(issuer_path).map_err(|e| format!("read issuer certificate: {e}"))?;
141 Box::new(
142 source
143 .with_issuer_certificate(&issuer)
144 .map_err(|e| format!("issuer certificate: {e}"))?,
145 )
146 }
147 #[cfg(feature = "dogtag-sync")]
148 hoike_core::config::SourceConfig::DogtagSync { .. } => {
149 return Err(format!(
150 "CA '{}': DogtagSync source must be persistent but was not found",
151 ca_config.label
152 ));
153 }
154 #[cfg(not(feature = "dogtag-sync"))]
155 hoike_core::config::SourceConfig::DogtagSync { .. } => {
156 return Err("dogtag-sync requires the 'dogtag-sync' feature flag".into());
157 }
158 };
159 *fresh_holder = Some(fresh);
160 Ok(fresh_holder.as_ref().unwrap().as_ref())
161}
162
163pub fn sign_ca_scope(
167 config: &Config,
168 ca_config: &CaConfig,
169 source: &dyn RevocationSource,
170) -> std::result::Result<SignedScope, String> {
171 let ca = CaIdentity {
172 label: ca_config.label.clone(),
173 issuer_name_der: decode_issuer_name(ca_config)?,
174 issuer_key_bytes: decode_issuer_key(ca_config)?,
175 };
176
177 let snapshot = source
178 .snapshot(&ca)
179 .map_err(|e| format!("snapshot failed for {}: {e}", ca_config.label))?;
180
181 let epoch = {
185 let state_db_path = config.storage.state_db.join("state.json");
186 let store = hoike_core::StateStore::open(&state_db_path)
187 .map_err(|e| format!("state store: {e}"))?;
188 let issuer_key_hash_hex = hex::encode(sha2::Sha256::digest(&ca.issuer_key_bytes));
189 store
190 .get_high_water(COMBINED_PRODUCER_ID, &issuer_key_hash_hex)
191 .unwrap_or(0)
192 .saturating_add(1)
193 };
194
195 let gen_config = GenerationConfig {
196 producer_id: COMBINED_PRODUCER_ID.into(),
197 epoch,
198 validity_secs: ca_config.validity_secs,
199 jitter_secs: ca_config.jitter_secs,
200 certid_compat: crate::CertIdCompat::parse(&ca_config.certid_compat)?,
201 completeness: match ca_config.completeness.as_str() {
202 "authoritative-complete" if source.is_authoritative_complete() => {
203 ahu::Completeness::AuthoritativeComplete
204 }
205 "authoritative-complete" => {
206 return Err(format!(
207 "CA '{}': source cannot prove authoritative completeness; configure partial",
208 ca_config.label
209 ));
210 }
211 "partial" => ahu::Completeness::Partial,
212 other => return Err(format!("unknown completeness: {other}")),
213 },
214 archive_cutoff_secs: ca_config.archive_cutoff_secs,
215 ..Default::default()
216 };
217
218 if ca_config.nonce_policy == "live" {
220 crate::live::load_live_material(ca_config)?;
221 }
222 let responder_cert_der = load_responder_cert(ca_config)?;
223 let (seal_key, seal_cert_der) = load_seal_materials(ca_config)?;
224
225 let bundle_bytes = produce_scope_bundle(
226 ca_config,
227 &ca,
228 &snapshot,
229 &gen_config,
230 &seal_key,
231 &seal_cert_der,
232 responder_cert_der.as_deref(),
233 )?;
234
235 Ok(SignedScope {
236 label: ca_config.label.clone(),
237 entry_count: snapshot.entries.len(),
238 epoch,
239 bytes: bundle_bytes,
240 })
241}
242
243pub fn revoked_serials_for_scope(
248 ca_config: &CaConfig,
249 persistent_sources: &PersistentSources,
250) -> std::result::Result<std::collections::BTreeSet<crate::source::SerialBytes>, String> {
251 let ca = CaIdentity {
252 label: ca_config.label.clone(),
253 issuer_name_der: decode_issuer_name(ca_config)?,
254 issuer_key_bytes: decode_issuer_key(ca_config)?,
255 };
256 let mut fresh_holder: Option<Box<dyn RevocationSource>> = None;
257 let source = resolve_source(ca_config, persistent_sources, &mut fresh_holder)?;
258 let snapshot = source
259 .snapshot(&ca)
260 .map_err(|e| format!("snapshot failed for {}: {e}", ca_config.label))?;
261 Ok(snapshot
262 .entries
263 .into_iter()
264 .filter_map(|(serial, status)| {
265 matches!(status, crate::CertificateStatus::Revoked { .. }).then_some(serial)
266 })
267 .collect())
268}
269
270pub fn sign_and_write_scope(
273 config: &Config,
274 persistent_sources: &PersistentSources,
275 ca_config: &CaConfig,
276) -> std::result::Result<SignedScope, String> {
277 let mut fresh_holder: Option<Box<dyn RevocationSource>> = None;
278 let source = resolve_source(ca_config, persistent_sources, &mut fresh_holder)?;
279 let signed = sign_ca_scope(config, ca_config, source)?;
280 let path = write_bundle(config, &signed.label, &signed.bytes)?;
281 info!(
282 ca = signed.label,
283 epoch = signed.epoch,
284 entries = signed.entry_count,
285 size = signed.bytes.len(),
286 path = %path.display(),
287 "bundle produced (CMS sealed)"
288 );
289 Ok(signed)
290}
291
292pub fn sign_and_write_all(
295 config: &Config,
296 persistent_sources: &PersistentSources,
297) -> std::result::Result<Vec<SignedScope>, String> {
298 let mut out = Vec::new();
299 for ca_config in &config.ca {
300 if ca_config.source.is_none() {
301 continue;
302 }
303 out.push(sign_and_write_scope(config, persistent_sources, ca_config)?);
304 }
305 Ok(out)
306}
307
308pub fn write_bundle(
311 config: &Config,
312 label: &str,
313 bytes: &[u8],
314) -> std::result::Result<PathBuf, String> {
315 let path = config
316 .ca
317 .iter()
318 .find(|ca| ca.label == label)
319 .and_then(|ca| ca.bundle_file.clone())
320 .unwrap_or_else(|| config.storage.bundle_dir.join(format!("{label}.ahu")));
321 write_bundle_atomic(&path, bytes)?;
322 Ok(path)
323}
324
325pub fn write_bundle_atomic(
327 path: &std::path::Path,
328 bytes: &[u8],
329) -> std::result::Result<(), String> {
330 use std::io::Write;
331 let parent = path
332 .parent()
333 .filter(|p| !p.as_os_str().is_empty())
334 .unwrap_or(std::path::Path::new("."));
335 std::fs::create_dir_all(parent).map_err(|e| format!("create bundle directory: {e}"))?;
336 let tmp = parent.join(format!(".hoike-{}.tmp", uuid::Uuid::now_v7()));
337 let result = (|| {
338 let mut file = std::fs::OpenOptions::new()
339 .write(true)
340 .create_new(true)
341 .open(&tmp)?;
342 file.write_all(bytes)?;
343 file.sync_all()?;
344 std::fs::rename(&tmp, path)?;
345 std::fs::File::open(parent)?.sync_all()?;
346 Ok::<_, std::io::Error>(())
347 })();
348 if result.is_err() {
349 let _ = std::fs::remove_file(&tmp);
350 }
351 result.map_err(|e| format!("publish {}: {e}", path.display()))
352}
353
354#[allow(clippy::too_many_arguments)]
358fn produce_scope_bundle(
359 ca_config: &CaConfig,
360 ca: &CaIdentity,
361 snapshot: &crate::StatusSnapshot,
362 gen_config: &GenerationConfig,
363 seal_key: &SealKey,
364 seal_cert_der: &[u8],
365 responder_cert_der: Option<&[u8]>,
366) -> std::result::Result<Vec<u8>, String> {
367 let bundle_bytes = if ca_config.is_ml_dsa() {
368 match &ca_config.signing_key {
369 Some(hoike_core::config::SigningKeyConfig::File { path }) => {
370 let mut v =
371 crate::load_ml_dsa_key(path).map_err(|e| format!("signing key: {e}"))?;
372 if v.algorithm_name() != ca_config.sig_alg {
373 return Err(format!(
374 "CA '{}': key is {} but sig_alg is {}",
375 ca_config.label,
376 v.algorithm_name(),
377 ca_config.sig_alg
378 ));
379 }
380 info!(ca = ca_config.label, key = %path.display(), alg = v.algorithm_name(), "using file-based ML-DSA signing key");
381 let sk = seal_key.clone();
382 let sc = seal_cert_der.to_vec();
383 v.sign_bundle(
384 ca,
385 snapshot,
386 gen_config,
387 move |m| crate::create_cms_seal(m, &sk, &sc),
388 responder_cert_der,
389 )
390 }
391 Some(hoike_core::config::SigningKeyConfig::Demo) => {
392 warn!(ca = ca_config.label, "using demo ML-DSA key — NOT FOR PRODUCTION");
393 let mut v = crate::MlDsaSignerVariant::demo(&ca_config.sig_alg)
394 .map_err(|e| format!("CA '{}': {e}", ca_config.label))?;
395 let sk = seal_key.clone();
396 let sc = seal_cert_der.to_vec();
397 v.sign_bundle(
398 ca,
399 snapshot,
400 gen_config,
401 move |m| crate::create_cms_seal(m, &sk, &sc),
402 responder_cert_der,
403 )
404 }
405 #[cfg(feature = "pkcs11")]
406 Some(hoike_core::config::SigningKeyConfig::Pkcs11 { .. }) => {
407 let pkcs11_config = resolve_pkcs11_config(ca_config)?;
408 let (param_set, oid) = ml_dsa_pkcs11_params(&ca_config.sig_alg)?;
409 let inner = crate::Pkcs11MlDsaSigner::new(&pkcs11_config, param_set, oid)
410 .map_err(|e| format!("PKCS#11 ML-DSA init: {e}"))?;
411 let mut bridge = crate::Pkcs11MlDsaSignerBridge::new(inner);
412 let sk = seal_key.clone();
413 let sc = seal_cert_der.to_vec();
414 crate::produce_bundle::<_, crate::Pkcs11MlDsaSignature>(
415 ca,
416 snapshot,
417 gen_config,
418 &mut bridge,
419 move |m| {
420 crate::create_cms_seal(m, &sk, &sc)
421 .map_err(|e| crate::SignError::Seal(e.to_string()))
422 },
423 responder_cert_der,
424 )
425 }
426 #[cfg(not(feature = "pkcs11"))]
427 Some(hoike_core::config::SigningKeyConfig::Pkcs11 { .. }) => {
428 return Err(format!(
429 "CA '{}' requires PKCS#11 but hoike was built without 'pkcs11' feature",
430 ca_config.label
431 ));
432 }
433 None => {
434 return Err(format!("CA '{}': no signing_key configured", ca_config.label));
435 }
436 }
437 } else {
438 match &ca_config.signing_key {
439 Some(hoike_core::config::SigningKeyConfig::File { path }) => {
440 let mut signing_key =
441 crate::load_ecdsa_p256_key(path).map_err(|e| format!("signing key: {e}"))?;
442 info!(ca = ca_config.label, key = %path.display(), "using file-based signing key");
443 let sk = seal_key.clone();
444 let sc = seal_cert_der.to_vec();
445 crate::produce_bundle::<_, p256::ecdsa::DerSignature>(
446 ca,
447 snapshot,
448 gen_config,
449 &mut signing_key,
450 move |m| crate::create_cms_seal(m, &sk, &sc),
451 responder_cert_der,
452 )
453 }
454 #[cfg(feature = "pkcs11")]
455 Some(hoike_core::config::SigningKeyConfig::Pkcs11 { .. }) => {
456 let pkcs11_config = resolve_pkcs11_config(ca_config)?;
457 let inner = crate::Pkcs11Signer::new(&pkcs11_config)
458 .map_err(|e| format!("PKCS#11 init: {e}"))?;
459 let mut bridge = crate::Pkcs11SignerBridge::new(inner);
460 let sk = seal_key.clone();
461 let sc = seal_cert_der.to_vec();
462 crate::produce_bundle::<_, crate::Pkcs11EcdsaSignature>(
463 ca,
464 snapshot,
465 gen_config,
466 &mut bridge,
467 move |m| {
468 crate::create_cms_seal(m, &sk, &sc)
469 .map_err(|e| crate::SignError::Seal(e.to_string()))
470 },
471 responder_cert_der,
472 )
473 }
474 #[cfg(not(feature = "pkcs11"))]
475 Some(hoike_core::config::SigningKeyConfig::Pkcs11 { .. }) => {
476 return Err(format!(
477 "CA '{}' requires PKCS#11 but hoike was built without 'pkcs11' feature",
478 ca_config.label
479 ));
480 }
481 Some(hoike_core::config::SigningKeyConfig::Demo) => {
482 warn!(ca = ca_config.label, "using demo signing key — NOT FOR PRODUCTION");
483 let mut signing_key = crate::demo_ecdsa_p256_key();
484 let sk = seal_key.clone();
485 let sc = seal_cert_der.to_vec();
486 crate::produce_bundle::<_, p256::ecdsa::DerSignature>(
487 ca,
488 snapshot,
489 gen_config,
490 &mut signing_key,
491 move |m| crate::create_cms_seal(m, &sk, &sc),
492 responder_cert_der,
493 )
494 }
495 None => {
496 return Err(format!("CA '{}': no signing_key configured", ca_config.label));
497 }
498 }
499 }
500 .map_err(|e| format!("bundle production failed for {}: {e}", ca_config.label))?;
501
502 Ok(bundle_bytes)
503}
504
505fn decode_b64_field(
510 b64: &Option<String>,
511 field_name: &str,
512 ca_label: &str,
513 fallback: &str,
514) -> std::result::Result<Vec<u8>, String> {
515 match b64 {
516 Some(val) => {
517 use base64::Engine;
518 base64::engine::general_purpose::STANDARD
519 .decode(val)
520 .map_err(|e| format!("CA '{}': invalid base64 in {}: {e}", ca_label, field_name))
521 }
522 None => Ok(fallback.as_bytes().to_vec()),
523 }
524}
525
526fn configured_issuer(ca: &CaConfig) -> std::result::Result<Option<x509_cert::Certificate>, String> {
527 if let Some(hoike_core::config::SourceConfig::Crl {
528 issuer_cert: Some(path),
529 ..
530 }) = &ca.source
531 {
532 let bytes = std::fs::read(path).map_err(|e| format!("read issuer certificate: {e}"))?;
533 return crate::rotation::parse_certificate(&bytes)
534 .map(Some)
535 .map_err(|e| e.to_string());
536 }
537 Ok(None)
538}
539
540pub fn decode_issuer_name(ca: &CaConfig) -> std::result::Result<Vec<u8>, String> {
542 if ca.issuer_name_der_b64.is_none() {
543 if let Some(cert) = configured_issuer(ca)? {
544 use der::Encode;
545 return cert
546 .tbs_certificate
547 .subject
548 .to_der()
549 .map_err(|e| e.to_string());
550 }
551 }
552 decode_b64_field(
553 &ca.issuer_name_der_b64,
554 "issuer_name_der_b64",
555 &ca.label,
556 &format!("CN={}", ca.label),
557 )
558}
559
560pub fn decode_issuer_key(ca: &CaConfig) -> std::result::Result<Vec<u8>, String> {
562 if ca.issuer_key_bytes_b64.is_none() {
563 if let Some(cert) = configured_issuer(ca)? {
564 return cert
565 .tbs_certificate
566 .subject_public_key_info
567 .subject_public_key
568 .as_bytes()
569 .map(|b| b.to_vec())
570 .ok_or_else(|| "issuer public key has unused bits".into());
571 }
572 }
573 decode_b64_field(
574 &ca.issuer_key_bytes_b64,
575 "issuer_key_bytes_b64",
576 &ca.label,
577 &format!("{}-key", ca.label),
578 )
579}
580
581#[cfg(feature = "dogtag-sync")]
583pub fn resolve_ldap_password(
584 password: Option<&str>,
585 env_var: Option<&str>,
586) -> std::result::Result<String, String> {
587 if let Some(pw) = password {
588 return Ok(pw.to_string());
589 }
590 if let Some(var) = env_var {
591 return std::env::var(var).map_err(|_| {
592 format!(
593 "LDAP bind password env var '{var}' not set. \
594 Set it or use bind_password in config."
595 )
596 });
597 }
598 Err("no LDAP bind password: set bind_password or bind_password_env in config".into())
599}
600
601pub fn load_responder_cert(ca: &CaConfig) -> std::result::Result<Option<Vec<u8>>, String> {
603 match &ca.responder_cert {
604 Some(path) => {
605 let data = std::fs::read(path)
606 .map_err(|e| format!("failed to read responder cert '{}': {e}", path.display()))?;
607 if data.starts_with(b"-----BEGIN") {
608 let pem_str = String::from_utf8(data)
609 .map_err(|e| format!("responder cert PEM is not valid UTF-8: {e}"))?;
610 let first_line = pem_str.lines().next().unwrap_or("");
612 if !first_line.contains("CERTIFICATE") {
613 return Err(format!(
614 "responder cert '{}' has unexpected PEM label: {} — expected CERTIFICATE",
615 path.display(),
616 first_line.trim()
617 ));
618 }
619 use base64::Engine;
620 let mut b64 = String::new();
621 for line in pem_str.lines() {
622 if line.starts_with("-----") {
623 continue;
624 }
625 b64.push_str(line.trim());
626 }
627 let der = base64::engine::general_purpose::STANDARD
628 .decode(&b64)
629 .map_err(|e| format!("responder cert PEM base64 decode: {e}"))?;
630 Ok(Some(der))
631 } else {
632 Ok(Some(data))
633 }
634 }
635 None => Ok(None),
636 }
637}
638
639pub fn load_seal_materials(
645 ca_config: &CaConfig,
646) -> std::result::Result<(SealKey, Vec<u8>), String> {
647 let seal_key = if let Some(path) = &ca_config.seal_key {
648 let ecdsa_key =
649 crate::load_ecdsa_p256_key(path).map_err(|e| format!("load seal key: {e}"))?;
650 SealKey::EcdsaP256(ecdsa_key)
651 } else if !ca_config.is_ml_dsa() {
652 if let Some(hoike_core::config::SigningKeyConfig::File { path }) = &ca_config.signing_key {
653 warn!(
654 ca = ca_config.label,
655 "using OCSP signing key as seal key — configure seal_key for production"
656 );
657 let ecdsa_key = crate::load_ecdsa_p256_key(path)
658 .map_err(|e| format!("load signing key for seal: {e}"))?;
659 SealKey::EcdsaP256(ecdsa_key)
660 } else {
661 warn!(
662 ca = ca_config.label,
663 "no seal_key configured — generating ephemeral seal key"
664 );
665 SealKey::EcdsaP256(crate::demo_ecdsa_p256_key())
666 }
667 } else {
668 warn!(
669 ca = ca_config.label,
670 "ML-DSA signing key cannot be used as P-256 seal key — \
671 configure seal_key for production; using ephemeral seal key"
672 );
673 SealKey::EcdsaP256(crate::demo_ecdsa_p256_key())
674 };
675
676 let seal_cert_der = if let Some(path) = &ca_config.seal_cert {
677 std::fs::read(path).map_err(|e| format!("read seal cert: {e}"))?
678 } else {
679 crate::generate_seal_cert_for_key(&seal_key)
680 .map_err(|e| format!("generate seal cert: {e}"))?
681 };
682
683 Ok((seal_key, seal_cert_der))
684}
685
686#[cfg(feature = "pkcs11")]
690pub fn resolve_pkcs11_config(
691 ca_config: &CaConfig,
692) -> std::result::Result<crate::Pkcs11Config, String> {
693 match &ca_config.signing_key {
694 Some(hoike_core::config::SigningKeyConfig::Pkcs11 {
695 module,
696 token_label,
697 slot_id,
698 pin,
699 pin_env,
700 key_label,
701 key_id,
702 }) => {
703 let resolved_pin = resolve_pkcs11_pin_noninteractive(&ca_config.label, pin, pin_env)?;
704 Ok(crate::Pkcs11Config {
705 module_path: module.clone(),
706 slot_id: *slot_id,
707 token_label: token_label.clone(),
708 pin: resolved_pin,
709 key_label: key_label.clone(),
710 key_id: key_id
711 .as_ref()
712 .map(|h| {
713 hex::decode(h).map_err(|e| {
714 format!(
715 "CA '{}': invalid hex in key_id '{}': {e}",
716 ca_config.label, h
717 )
718 })
719 })
720 .transpose()?,
721 })
722 }
723 _ => Err(format!(
724 "CA '{}': not a PKCS#11 signing key config",
725 ca_config.label
726 )),
727 }
728}
729
730#[cfg(feature = "pkcs11")]
732fn resolve_pkcs11_pin_noninteractive(
733 ca_label: &str,
734 pin: &Option<String>,
735 pin_env: &Option<String>,
736) -> std::result::Result<String, String> {
737 if let Some(p) = pin {
738 warn!(
739 ca = ca_label,
740 "PKCS#11 PIN is in config file — use pin_env for production"
741 );
742 return Ok(p.clone());
743 }
744 if let Some(env_var) = pin_env {
745 return std::env::var(env_var).map_err(|_| {
746 format!(
747 "CA '{}': PKCS#11 pin_env '{}' is not set in environment",
748 ca_label, env_var
749 )
750 });
751 }
752 Err(format!(
753 "CA '{}': PKCS#11 PIN required — set pin_env (on-demand signing cannot prompt interactively)",
754 ca_label
755 ))
756}
757
758#[cfg(feature = "pkcs11")]
760pub fn ml_dsa_pkcs11_params(
761 sig_alg: &str,
762) -> std::result::Result<(cryptoki::object::MlDsaParameterSetType, &'static str), String> {
763 match sig_alg {
764 "ml-dsa-44" => Ok((
765 cryptoki::object::MlDsaParameterSetType::ML_DSA_44,
766 crate::ML_DSA_44_OID,
767 )),
768 "ml-dsa-65" => Ok((
769 cryptoki::object::MlDsaParameterSetType::ML_DSA_65,
770 crate::ML_DSA_65_OID,
771 )),
772 "ml-dsa-87" => Ok((
773 cryptoki::object::MlDsaParameterSetType::ML_DSA_87,
774 crate::ML_DSA_87_OID,
775 )),
776 other => Err(format!("unknown ML-DSA variant for PKCS#11: {other}")),
777 }
778}
779
780#[cfg(test)]
781mod publication_tests {
782 use super::*;
783
784 #[test]
785 fn configured_bundle_destination_is_respected_and_failure_preserves_old_file() {
786 let dir = tempfile::tempdir().unwrap();
787 let destination = dir.path().join("custom/active.ahu");
788 let config_path = dir.path().join("hoike.toml");
789 std::fs::write(
790 &config_path,
791 format!(
792 r#"
793[server]
794[storage]
795bundle_dir = "{}/default"
796[[ca]]
797label = "test"
798bundle_file = "{}"
799"#,
800 dir.path().display(),
801 destination.display()
802 ),
803 )
804 .unwrap();
805 let config = Config::from_file(&config_path).unwrap();
806 assert_eq!(write_bundle(&config, "test", b"old").unwrap(), destination);
807 write_bundle(&config, "test", b"complete replacement").unwrap();
808 assert_eq!(
809 std::fs::read(&destination).unwrap(),
810 b"complete replacement"
811 );
812 assert!(!config.storage.bundle_dir.join("test.ahu").exists());
813 let blocked = dir.path().join("blocked");
815 std::fs::create_dir(&blocked).unwrap();
816 std::fs::write(blocked.join("previous"), b"old").unwrap();
817 assert!(write_bundle_atomic(&blocked, b"new").is_err());
818 assert_eq!(std::fs::read(blocked.join("previous")).unwrap(), b"old");
819 assert!(
820 !std::fs::read_dir(dir.path()).unwrap().any(|e| e
821 .unwrap()
822 .file_name()
823 .to_string_lossy()
824 .ends_with(".tmp"))
825 );
826 }
827}
828
829#[cfg(test)]
830mod urgent_tests {
831 use super::*;
832 use crate::source::{
833 CaIdentity, CertificateStatus, Epoch, RevocationSource, StatusChange, StatusSnapshot,
834 };
835
836 struct MockSource(Vec<(Vec<u8>, CertificateStatus)>);
839 impl RevocationSource for MockSource {
840 fn snapshot(&self, _ca: &CaIdentity) -> crate::Result<StatusSnapshot> {
841 let now = crate::source::unix_now()?;
842 Ok(StatusSnapshot {
843 entries: self.0.iter().cloned().collect(),
844 this_update: now,
845 next_update: Some(now + 86400),
846 ..Default::default()
847 })
848 }
849 fn changes_since(
850 &self,
851 _ca: &CaIdentity,
852 _since: Epoch,
853 ) -> crate::Result<Vec<StatusChange>> {
854 Ok(vec![])
855 }
856 fn supports_streaming(&self) -> bool {
857 false
858 }
859 fn is_authoritative_complete(&self) -> bool {
860 false
861 }
862 }
863
864 #[test]
865 fn revoked_serials_for_scope_returns_only_revoked() {
866 let dir = tempfile::tempdir().unwrap();
868 let toml = format!(
869 r#"
870[server]
871[storage]
872bundle_dir = "{}/bundles"
873[[ca]]
874label = "test"
875issuer_name_der_b64 = "bmFtZQ=="
876issuer_key_bytes_b64 = "a2V5"
877"#,
878 dir.path().display()
879 );
880 let config_path = dir.path().join("hoike.toml");
881 std::fs::write(&config_path, toml).unwrap();
882 let config = Config::from_file(&config_path).unwrap();
883
884 let source = MockSource(vec![
885 (vec![0x01], CertificateStatus::Good),
886 (
887 vec![0x02],
888 CertificateStatus::Revoked {
889 revocation_time: 1_700_000_000,
890 reason: None,
891 },
892 ),
893 (
894 vec![0x03],
895 CertificateStatus::Revoked {
896 revocation_time: 1_700_000_100,
897 reason: None,
898 },
899 ),
900 ]);
901 let mut sources = PersistentSources::new();
902 sources.insert("test".to_string(), Box::new(source));
903
904 let revoked = revoked_serials_for_scope(&config.ca[0], &sources).unwrap();
905 assert_eq!(revoked.len(), 2);
906 assert!(revoked.contains(&vec![0x02]));
907 assert!(revoked.contains(&vec![0x03]));
908 assert!(!revoked.contains(&vec![0x01]));
909 }
910}