Skip to main content

hoike_sign/
orchestrate.rs

1//! Bundle-production orchestration shared by the CLI signer loop and the admin
2//! signing API.
3//!
4//! This module turns a `(Config, CaConfig, RevocationSource)` into signed,
5//! CMS-sealed bundle bytes — handling revocation snapshotting, anti-rollback
6//! epoch derivation, signing-key source dispatch (ECDSA / ML-DSA × file / demo /
7//! PKCS#11), seal materials, and responder-cert loading.
8//!
9//! It deliberately contains no interactive I/O: PKCS#11 PINs are resolved only
10//! from config or the environment, never a terminal prompt. The CLI keeps its
11//! own interactive PIN path for the one-shot `hoike sign` subcommand.
12
13use 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
22/// Producer ID stamped into combined-mode bundles.
23///
24/// This string is part of the anti-rollback high-water-mark key, so the
25/// background signer loop and the on-demand admin handler MUST use the same
26/// value or they will derive divergent epochs for the same scope.
27pub const COMBINED_PRODUCER_ID: &str = "hoike-combined";
28
29/// Map of CA label → persistent revocation source. Stateful sources (DogtagSync)
30/// retain their in-memory snapshot and sync cookie across signer passes and must
31/// be shared, not rebuilt, between the background loop and on-demand signing.
32pub type PersistentSources = HashMap<String, Box<dyn RevocationSource>>;
33
34/// Outcome of signing one CA scope.
35#[derive(Debug, Clone)]
36pub struct SignedScope {
37    pub label: String,
38    /// Serialized, CMS-sealed bundle bytes.
39    pub bytes: Vec<u8>,
40    pub entry_count: usize,
41    /// Epoch assigned to this generation (persisted high-water + 1).
42    pub epoch: u64,
43}
44
45/// Build the persistent revocation sources for a config. Only stateful sources
46/// (DogtagSync) are created here; stateless sources (CRL) are resolved fresh at
47/// signing time.
48#[allow(unused_mut)]
49#[allow(unused_variables)]
50// Without `dogtag-sync`, the match reduces to `_ => continue` (all arms diverge),
51// making the trailing insert unreachable. It IS reached when the feature is on.
52#[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            // CRL and other stateless sources are created fresh each pass.
98            _ => continue,
99        };
100        sources.insert(ca_config.label.clone(), source);
101    }
102    Ok(sources)
103}
104
105/// Resolve the revocation source for a CA: prefer the shared persistent source,
106/// otherwise build a fresh stateless one (CRL). Returns an owned box for the
107/// fresh case so the caller can hold it alongside the borrowed persistent case.
108fn 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
163/// Produce a signed bundle for one CA scope from an already-resolved revocation
164/// source. Pure with respect to the filesystem for the *bundle* (does not write
165/// the `.ahu`); it does read the state store to derive the epoch.
166pub 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    // Derive epoch from the persisted high-water mark — never from wall-clock
182    // time, which can step backward (NTP correction, VM restore) and permanently
183    // lock out mirrors.
184    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    // Also protect on-demand generation against invalid live key/cert replacements.
219    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
243/// Snapshot a CA scope and return the set of currently-revoked serials, without
244/// signing or writing anything. Used by the signer loop's urgent-revocation
245/// detector to diff against the previously-known revoked set between scheduled
246/// passes. Reuses the shared persistent source so a syncrepl cookie is not reset.
247pub 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
270/// Sign one CA scope by label, resolving its source, and write the resulting
271/// `.ahu` to the configured bundle directory. Used by the on-demand admin API.
272pub 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
292/// Sign every configured CA that has a revocation source, writing each `.ahu`.
293/// CAs without a source are skipped. Returns the list of signed scopes.
294pub 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
308/// Write bundle bytes to `{bundle_dir}/{label}.ahu`, creating the directory if
309/// needed. Returns the written path.
310pub 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
325/// Durable replacement: readers see either complete old or complete new bytes.
326pub 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/// Dispatch bundle production over the CA's configured signing-key source and
355/// algorithm. This is the single place the ECDSA/ML-DSA × file/demo/PKCS#11
356/// matrix lives.
357#[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
505// ---------------------------------------------------------------------------
506// Config-derived material loading (moved from the CLI so both surfaces share it)
507// ---------------------------------------------------------------------------
508
509fn 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
540/// Decode the issuer DN (DER) for a CA, falling back to a synthetic `CN=<label>`.
541pub 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
560/// Decode the issuer public-key bytes for a CA, falling back to a synthetic key.
561pub 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/// Resolve the LDAP bind password from config or environment (no interactive I/O).
582#[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
601/// Load and normalize a responder certificate to DER, if configured.
602pub 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                // Validate the PEM label — reject non-certificate files.
611                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
639/// Load the seal key and certificate for CMS bundle sealing.
640///
641/// Falls back to the OCSP signing key if no `seal_key` is configured and the
642/// signing key is ECDSA P-256. For ML-DSA signing keys (which are not P-256),
643/// falls back to an ephemeral demo seal key with a warning.
644pub 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/// Resolve a PKCS#11 config from a CA's signing-key config. PIN resolution is
687/// non-interactive: `pin` → `pin_env` → error. (The CLI's one-shot `sign`
688/// command has its own interactive-prompt variant.)
689#[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/// Non-interactive PKCS#11 PIN resolution: config value, then environment.
731#[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/// Map an ML-DSA sig-alg string to its PKCS#11 parameter set and OID.
759#[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        // A rename onto a directory fails after the temporary file is written.
814        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    /// A revocation source with a fixed set of entries, for exercising the
837    /// urgent-revocation detector without a live directory or CRL.
838    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        // issuer_name_der_b64 = base64("name"), issuer_key_bytes_b64 = base64("key")
867        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}