Skip to main content

hoike/
main.rs

1use clap::Parser;
2use std::path::PathBuf;
3use std::sync::Arc;
4use tracing::{error, info, warn};
5
6#[derive(Parser)]
7#[command(
8    name = "hoike",
9    about = "hoike — OCSP responder for pre-signed ahu bundles"
10)]
11struct Cli {
12    #[command(subcommand)]
13    command: Commands,
14}
15
16#[derive(clap::Subcommand)]
17#[allow(clippy::large_enum_variant)]
18enum Commands {
19    /// Start the OCSP responder
20    Serve {
21        /// Config file path
22        #[arg(long, default_value = "hoike.toml")]
23        config: PathBuf,
24    },
25    /// Validate configuration, bundle, and connectivity
26    Check {
27        /// Config file path
28        #[arg(long, default_value = "hoike.toml")]
29        config: PathBuf,
30    },
31    /// Produce a signed ahu bundle from a revocation source
32    Sign {
33        /// CA label
34        #[arg(long)]
35        ca: String,
36        /// CRL file to ingest (DER or PEM)
37        #[arg(long)]
38        crl: PathBuf,
39        /// Output bundle path
40        #[arg(short, long, default_value = "output.ahu")]
41        output: PathBuf,
42        /// Issuer certificate (DER) for CertID computation
43        #[arg(long)]
44        issuer: Option<PathBuf>,
45        /// Epoch number for this generation
46        #[arg(long, default_value = "1")]
47        epoch: u64,
48        /// CertID compatibility mode
49        #[arg(long, default_value = "dual")]
50        certid_compat: String,
51        /// Include known-good serials from a file (one hex serial per line)
52        #[arg(long)]
53        good_serials: Option<PathBuf>,
54        /// Signing algorithm: ecdsa-p256 (default), ml-dsa-44, ml-dsa-65, ml-dsa-87
55        #[arg(long, default_value = "ecdsa-p256")]
56        sig_alg: String,
57        /// Base64-encoded DER issuer name (for correct CertID hashes)
58        #[arg(long)]
59        issuer_name_b64: Option<String>,
60        /// Base64-encoded issuer public key bytes (for correct CertID hashes)
61        #[arg(long)]
62        issuer_key_b64: Option<String>,
63        /// Path to PKCS#8 PEM or DER signing key file
64        #[arg(long, conflicts_with = "demo_key")]
65        signing_key: Option<PathBuf>,
66        /// Path to PKCS#8 PEM or DER P-256 seal key file (separate from signing key)
67        #[arg(long)]
68        seal_key: Option<PathBuf>,
69        /// Produce a dual-algorithm bundle: --sig-alg is the classical algorithm,
70        /// --dual-alg is the post-quantum algorithm (e.g. ml-dsa-87)
71        #[arg(long)]
72        dual_alg: Option<String>,
73        /// Path to PKCS#8 PEM or DER PQ signing key file (for --dual-alg)
74        #[arg(long)]
75        pq_signing_key: Option<PathBuf>,
76        /// Use an ephemeral demo key (NOT FOR PRODUCTION)
77        #[arg(long)]
78        demo_key: bool,
79    },
80    /// Query a running OCSP responder with optional algorithm preference
81    Query {
82        /// Responder URL (e.g. http://localhost:2560)
83        #[arg(long)]
84        url: String,
85        /// Hex-encoded serial number
86        #[arg(long)]
87        serial: String,
88        /// Base64-encoded DER issuer name
89        #[arg(long)]
90        issuer_name_b64: String,
91        /// Base64-encoded issuer public key bytes
92        #[arg(long)]
93        issuer_key_b64: String,
94        /// Preferred signature algorithms (comma-separated, e.g. "ml-dsa-87,ecdsa-p256")
95        #[arg(long)]
96        prefer: Option<String>,
97    },
98    /// Import a bundle into the responder's bundle directory (for enclave/air-gap deployments)
99    Import {
100        /// Path to the .ahu bundle to import
101        #[arg(long)]
102        bundle: PathBuf,
103        /// Config file (to determine bundle_dir)
104        #[arg(long, default_value = "hoike.toml")]
105        config: PathBuf,
106        /// Skip anti-rollback check (for first import into a fresh enclave)
107        #[arg(long)]
108        force: bool,
109    },
110}
111
112fn main() {
113    let cli = Cli::parse();
114
115    tracing_subscriber::fmt()
116        .with_env_filter(
117            tracing_subscriber::EnvFilter::try_from_default_env()
118                .unwrap_or_else(|_| "info,tower_http=debug".parse().unwrap()),
119        )
120        .init();
121
122    match cli.command {
123        Commands::Serve { config } => {
124            let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
125            rt.block_on(run_server(config));
126        }
127        Commands::Check { config } => {
128            run_check(config);
129        }
130        Commands::Sign {
131            ca,
132            crl,
133            output,
134            issuer,
135            epoch,
136            certid_compat,
137            good_serials,
138            sig_alg,
139            issuer_name_b64,
140            issuer_key_b64,
141            signing_key,
142            seal_key,
143            dual_alg,
144            pq_signing_key,
145            demo_key,
146        } => {
147            run_sign(
148                ca,
149                crl,
150                output,
151                issuer,
152                epoch,
153                certid_compat,
154                good_serials,
155                sig_alg,
156                issuer_name_b64,
157                issuer_key_b64,
158                signing_key,
159                seal_key,
160                dual_alg,
161                pq_signing_key,
162                demo_key,
163            );
164        }
165        Commands::Query {
166            url,
167            serial,
168            issuer_name_b64,
169            issuer_key_b64,
170            prefer,
171        } => {
172            run_query(url, serial, issuer_name_b64, issuer_key_b64, prefer);
173        }
174        Commands::Import {
175            bundle,
176            config,
177            force,
178        } => {
179            run_import(bundle, config, force);
180        }
181    }
182}
183
184async fn run_server(config_path: PathBuf) {
185    let config = hoike_core::Config::from_file(&config_path).unwrap_or_else(|e| {
186        eprintln!("Failed to load config from {}: {e}", config_path.display());
187        std::process::exit(1);
188    });
189
190    if let Err(e) = config.validate_transport(cfg!(feature = "tls")) {
191        eprintln!("Configuration error: {e}");
192        std::process::exit(1);
193    }
194
195    let listen = config.server.listen.clone();
196    let needs_signing = config.needs_signing();
197
198    // Recover expired delegated certificates before initial signing or live loading.
199    if needs_signing {
200        for ca in &config.ca {
201            if let Err(e) = prepare_signer_material(ca).await {
202                eprintln!("Signing material preflight failed for {}: {e}", ca.label);
203                std::process::exit(1);
204            }
205        }
206    }
207
208    // Create persistent revocation sources — stateful sources like DogtagSync
209    // must survive across signer loop iterations to retain their in-memory
210    // snapshot and sync cookie. Needed for both combined and signer modes.
211    let persistent_sources = if needs_signing {
212        Some(
213            hoike_sign::create_persistent_sources(&config).unwrap_or_else(|e| {
214                eprintln!("Failed to create revocation sources: {e}");
215                std::process::exit(1);
216            }),
217        )
218    } else {
219        None
220    };
221
222    // In signing modes, run an initial signing pass before loading bundles so
223    // there's something to serve immediately (combined) or a fresh generation
224    // on disk (signer).
225    if let Some(sources) = persistent_sources.as_ref() {
226        info!(
227            mode = config.server.mode,
228            "running initial bundle production"
229        );
230        if let Err(e) = hoike_sign::sign_and_write_all(&config, sources) {
231            eprintln!("Initial signer pass failed: {e}");
232            std::process::exit(1);
233        }
234    }
235
236    // Install the Prometheus recorder (no-op unless built with --features
237    // metrics) before the first bundle load so an initial-load failure is
238    // captured in `hoike_bundle_load_failures_total` too, not just later reloads.
239    if hoike_server::install_metrics() {
240        info!("Prometheus metrics recorder installed");
241    }
242
243    let state = hoike_core::ResponderState::load(config.clone()).unwrap_or_else(|e| {
244        hoike_server::obs::record_bundle_load_failure(
245            "all",
246            hoike_server::obs::load_failure_reason(&e),
247        );
248        hoike_server::obs::audit!(
249            event = "bundle_load_failed",
250            trigger = "initial_load",
251            reason = hoike_server::obs::load_failure_reason(&e),
252            error = %e,
253            "initial bundle load failed"
254        );
255        eprintln!("Failed to initialize responder: {e}");
256        std::process::exit(1);
257    });
258
259    let mut app_state = hoike_server::AppState::new(state, config.clone());
260
261    // Attach the shared signing context (holds the persistent sources behind a
262    // mutex) so the admin API and the background loop serialize on one lock.
263    if let Some(sources) = persistent_sources {
264        let ctx = hoike_server::SignerContext {
265            sources: tokio::sync::Mutex::new(sources),
266        };
267        app_state = app_state.with_signer_context(ctx);
268    }
269
270    // A distinct signer is required for every live CA.
271    for ca in config.ca.iter().filter(|ca| ca.nonce_policy == "live") {
272        let live = load_live_signer(ca).unwrap_or_else(|e| {
273            eprintln!("Failed to load live signer for {}: {e}", ca.label);
274            std::process::exit(1);
275        });
276        app_state = app_state.with_live_signer_for(&ca.label, live);
277    }
278
279    // Start gossip BEFORE the signer loop so the loop can announce generations
280    // through the live node. The handle is kept (wrapped in `Arc`, attached to
281    // `AppState`) rather than dropped, so admin fleet endpoints and the signer
282    // path can reach membership, the generation table, and `announce_generation`.
283    if let Some(gossip_cfg) = &config.gossip {
284        if gossip_cfg.enabled {
285            let (msg_tx, mut msg_rx) =
286                tokio::sync::mpsc::channel::<hoike_gossip::GossipMessage>(256);
287
288            let gc = hoike_gossip::GossipConfig {
289                enabled: true,
290                bind: gossip_cfg.bind.clone(),
291                seeds: gossip_cfg.seeds.clone(),
292                node_name: gossip_cfg.node_name.clone(),
293                identity_key: gossip_cfg.identity_key.clone(),
294                peer_keys: gossip_cfg.peer_keys.clone(),
295                peer_identities: gossip_cfg.peer_identities.clone(),
296            };
297
298            match hoike_gossip::GossipNode::start(gc, msg_tx).await {
299                Ok(gossip_node) => {
300                    info!("gossip node started");
301                    let gossip_node = std::sync::Arc::new(gossip_node);
302                    app_state = app_state.with_gossip(gossip_node.clone());
303
304                    // Consumer: fold received announcements into the generation
305                    // table so the fleet view can attribute epochs to nodes and
306                    // compute per-node staleness.
307                    let consumer_node = gossip_node.clone();
308                    tokio::spawn(async move {
309                        while let Some(msg) = msg_rx.recv().await {
310                            match &msg {
311                                hoike_gossip::GossipMessage::GenerationAnnouncement {
312                                    producer_id,
313                                    epoch,
314                                    origin_node,
315                                    bundle_url,
316                                    ..
317                                } => {
318                                    info!(
319                                        origin = %origin_node,
320                                        producer = %producer_id,
321                                        epoch,
322                                        url = bundle_url.as_deref().unwrap_or("none"),
323                                        "received generation announcement"
324                                    );
325                                    consumer_node.record_generation(&msg).await;
326                                    // Bundle pull-on-announce is future work: fetch
327                                    // from bundle_url, verify anti-rollback, then
328                                    // swap into the router via responder.reload().
329                                }
330                                hoike_gossip::GossipMessage::UrgentRevocation {
331                                    producer_id,
332                                    epoch,
333                                    origin_node,
334                                    ..
335                                } => {
336                                    info!(
337                                        origin = %origin_node,
338                                        producer = %producer_id,
339                                        epoch,
340                                        "received urgent revocation notice — would pull delta"
341                                    );
342                                }
343                            }
344                        }
345                    });
346                }
347                Err(e) => {
348                    warn!(error = %e, "gossip startup failed, continuing without gossip");
349                }
350            }
351        }
352    }
353
354    // In signing modes, start the background signer loop. It shares the same
355    // `SignerContext` mutex as the admin API so on-demand and periodic signing
356    // never race on epoch/cookie/write. It also gets the gossip handle so each
357    // successful pass announces the new generation to the mesh.
358    if needs_signing {
359        if let Some(ctx) = app_state.signer.clone() {
360            let signer_state = app_state.clone();
361            let signer_config = config.clone();
362            let signer_gossip = app_state.gossip.clone();
363            tokio::spawn(async move {
364                run_signer_loop(signer_state, signer_config, ctx, signer_gossip).await;
365            });
366        }
367    }
368
369    // The recorder was installed earlier (before the initial load). If
370    // configured, expose it on a dedicated private listener — never the public
371    // OCSP port. Optionally TLS-terminated (server.metrics_tls).
372    if let Some(metrics_listen) = config.server.metrics_listen.clone() {
373        let metrics_state = app_state.clone();
374        let metrics_tls = config.server.metrics_tls.clone();
375        tokio::spawn(async move {
376            let router = hoike_server::build_metrics_router(metrics_state);
377            serve_listener("metrics", &metrics_listen, router, metrics_tls).await;
378        });
379    }
380
381    // Dedicated admin/UI listener (NIAP PPCA FTP_TRP.1 trusted path). When
382    // `admin_listen` is set, the management surface moves off the OCSP port and
383    // TLS terminates here, leaving the OCSP data plane plaintext. When unset,
384    // admin/UI stay nested on the OCSP router (legacy single-port layout).
385    if let Some(admin_listen) = config.server.admin_listen.clone() {
386        let admin_state = app_state.clone();
387        let admin_tls = config.server.admin_tls.clone();
388        if admin_tls.is_none() {
389            warn!(
390                listen = %admin_listen,
391                "admin listener configured without admin_tls — management surface is PLAINTEXT"
392            );
393        }
394        tokio::spawn(async move {
395            let router = hoike_server::build_admin_router_standalone(admin_state);
396            serve_listener("admin", &admin_listen, router, admin_tls).await;
397        });
398    }
399
400    let app = hoike_server::build_router(app_state);
401
402    let listener = tokio::net::TcpListener::bind(&listen)
403        .await
404        .unwrap_or_else(|e| {
405            eprintln!("Failed to bind to {listen}: {e}");
406            std::process::exit(1);
407        });
408
409    info!(listen = %listen, mode = config.server.mode, "hoike OCSP responder starting");
410    axum::serve(listener, app).await.unwrap();
411}
412
413/// Serve an axum router on `addr`, using TLS when `tls` is `Some` and the binary
414/// was built with `--features tls`. Falls back to plaintext otherwise (warning
415/// if TLS was requested but unavailable). Used for the metrics and admin
416/// management listeners; the OCSP data plane is always plaintext.
417async fn serve_listener(
418    name: &str,
419    addr: &str,
420    router: axum::Router,
421    tls: Option<hoike_core::config::TlsConfig>,
422) {
423    #[cfg(feature = "tls")]
424    if let Some(tls) = tls.as_ref() {
425        info!(listen = %addr, listener = name, "TLS listener starting");
426        if let Err(e) = hoike_server::tls::serve_router_tls(addr, router, tls).await {
427            warn!(error = %e, listener = name, "TLS listener exited");
428        }
429        return;
430    }
431    #[cfg(not(feature = "tls"))]
432    if tls.is_some() {
433        warn!(
434            listener = name,
435            "TLS configured but binary built without --features tls — listener disabled"
436        );
437        return;
438    }
439
440    match tokio::net::TcpListener::bind(addr).await {
441        Ok(l) => {
442            info!(listen = %addr, listener = name, "listener starting");
443            if let Err(e) = axum::serve(l, router).await {
444                warn!(error = %e, listener = name, "listener exited");
445            }
446        }
447        Err(e) => {
448            warn!(error = %e, listen = %addr, listener = name, "failed to bind listener")
449        }
450    }
451}
452
453/// Renewal runs before startup and every generation; an invalid replacement
454/// must never reach bundle publication. Valid external renewals need no command.
455async fn prepare_signer_material(ca: &hoike_core::config::CaConfig) -> Result<(), String> {
456    if let Some(cert) = hoike_sign::orchestrate::load_responder_cert(ca)? {
457        let renew_before = ca
458            .key_rotation
459            .as_ref()
460            .map(|r| r.renew_before_days.saturating_mul(86400))
461            .unwrap_or(7 * 86400);
462        let status = hoike_sign::check_and_log_rotation(&ca.label, &cert, renew_before)
463            .map_err(|e| e.to_string())?;
464        if matches!(
465            status,
466            hoike_sign::RotationStatus::RenewSoon { .. } | hoike_sign::RotationStatus::Expired
467        ) {
468            if let Some(command) = ca
469                .key_rotation
470                .as_ref()
471                .and_then(|r| r.rotation_command.clone())
472            {
473                let label = ca.label.clone();
474                tokio::task::spawn_blocking(move || {
475                    hoike_sign::run_rotation_command(&label, &command)
476                })
477                .await
478                .map_err(|e| e.to_string())??;
479            }
480        }
481        // Re-read after rotation, including on restart with an expired certificate.
482        let replacement = hoike_sign::orchestrate::load_responder_cert(ca)?
483            .ok_or("responder certificate disappeared")?;
484        let parsed =
485            hoike_sign::rotation::parse_certificate(&replacement).map_err(|e| e.to_string())?;
486        let now = std::time::SystemTime::now()
487            .duration_since(std::time::UNIX_EPOCH)
488            .map_err(|e| e.to_string())?
489            .as_secs();
490        if now
491            < parsed
492                .tbs_certificate
493                .validity
494                .not_before
495                .to_unix_duration()
496                .as_secs()
497            || now
498                >= parsed
499                    .tbs_certificate
500                    .validity
501                    .not_after
502                    .to_unix_duration()
503                    .as_secs()
504        {
505            return Err("responder certificate is not currently valid after renewal check".into());
506        }
507    }
508    if ca.nonce_policy == "live" {
509        load_live_signer(ca)?;
510    }
511    Ok(())
512}
513
514/// Off-cycle urgent-revocation poll cadence (seconds). When any CA enables
515/// `urgent_revocation`, the loop wakes at `min(batch_interval, URGENT_POLL_SECS)`
516/// and, on the wakes between full passes, checks for newly-revoked serials so a
517/// fresh revocation does not wait a whole batch interval to propagate.
518const URGENT_POLL_SECS: u64 = 300;
519
520async fn run_signer_loop(
521    state: hoike_server::AppState,
522    config: hoike_core::Config,
523    ctx: Arc<hoike_server::SignerContext>,
524    gossip: Option<Arc<hoike_gossip::GossipNode>>,
525) {
526    let min_interval = config
527        .ca
528        .iter()
529        .map(|c| c.batch_interval)
530        .min()
531        .unwrap_or(3600);
532
533    let any_urgent = config
534        .ca
535        .iter()
536        .any(|c| c.urgent_revocation && c.source.is_some());
537    let poll_interval = if any_urgent {
538        min_interval.min(URGENT_POLL_SECS)
539    } else {
540        min_interval
541    };
542
543    info!(
544        interval_secs = min_interval,
545        poll_secs = poll_interval,
546        urgent = any_urgent,
547        mode = config.server.mode,
548        "signer loop starting"
549    );
550
551    // Seed the per-CA known-revoked sets so certificates already revoked at
552    // startup do not trigger an off-cycle pass on the first wake.
553    let mut known_revoked: std::collections::HashMap<String, std::collections::BTreeSet<Vec<u8>>> =
554        std::collections::HashMap::new();
555    if any_urgent {
556        let sources = ctx.sources.lock().await;
557        for ca in &config.ca {
558            if ca.urgent_revocation && ca.source.is_some() {
559                match hoike_sign::revoked_serials_for_scope(ca, &sources) {
560                    Ok(set) => {
561                        known_revoked.insert(ca.label.clone(), set);
562                    }
563                    Err(e) => {
564                        warn!(ca = ca.label, error = %e, "urgent-revocation seed snapshot failed")
565                    }
566                }
567            }
568        }
569    }
570
571    let mut elapsed_since_full = 0u64;
572
573    loop {
574        tokio::time::sleep(std::time::Duration::from_secs(poll_interval)).await;
575        elapsed_since_full = elapsed_since_full.saturating_add(poll_interval);
576        let do_full = elapsed_since_full >= min_interval;
577
578        // Off-cycle urgent-revocation check on the wakes between full passes.
579        // A full pass covers new revocations anyway, so it is skipped there.
580        if any_urgent && !do_full {
581            urgent_revocation_pass(&state, &config, &ctx, &gossip, &mut known_revoked).await;
582            continue;
583        }
584        if !do_full {
585            continue;
586        }
587        elapsed_since_full = 0;
588
589        // Serialize key rotation and generation against on-demand management.
590        let sources = ctx.sources.lock().await;
591        let mut material_ready = true;
592        for ca in &config.ca {
593            match prepare_signer_material(ca).await {
594                Ok(()) => {
595                    if let Err(e) = state.reload_live_signer(ca) {
596                        error!(ca = ca.label, error = %e, "live material reload failed");
597                        material_ready = false;
598                    }
599                }
600                Err(e) => {
601                    error!(ca = ca.label, error = %e, "signing material rejected; preserving active generation");
602                    material_ready = false;
603                }
604            }
605        }
606        if !material_ready {
607            continue;
608        }
609
610        info!("signer loop: starting production cycle");
611        let gen_start = std::time::Instant::now();
612        match hoike_sign::sign_and_write_all(&config, &sources) {
613            Ok(signed) => {
614                let gen_secs = gen_start.elapsed().as_secs_f64();
615                for s in &signed {
616                    hoike_server::obs::record_signer_generation(&s.label, gen_secs);
617                    hoike_server::obs::audit!(
618                        event = "signer_generation",
619                        ca = %s.label,
620                        trigger = "scheduled",
621                        epoch = s.epoch,
622                        entry_count = s.entry_count,
623                        "produced bundle on schedule"
624                    );
625                }
626                if let Err(e) = state.responder.reload() {
627                    hoike_server::obs::record_bundle_load_failure(
628                        "all",
629                        hoike_server::obs::load_failure_reason(&e),
630                    );
631                    hoike_server::obs::audit!(
632                        event = "bundle_load_failed",
633                        trigger = "scheduled_reload",
634                        reason = hoike_server::obs::load_failure_reason(&e),
635                        error = %e,
636                        "reload failed after scheduled production"
637                    );
638                    error!(error = %e, "signer loop: reload failed after production");
639                } else {
640                    info!(
641                        scopes = signed.len(),
642                        "signer loop: bundles produced and reloaded"
643                    );
644                    // Announce each new generation to the mesh (best-effort).
645                    if let Some(g) = gossip.as_ref() {
646                        for s in &signed {
647                            hoike_server::announce_bundle_scopes(g, &s.bytes).await;
648                        }
649                    }
650                }
651            }
652            Err(e) => {
653                hoike_server::obs::audit!(
654                    event = "signer_generation_failed",
655                    trigger = "scheduled",
656                    error = %e,
657                    "scheduled production failed"
658                );
659                error!(error = %e, "signer loop: production failed");
660            }
661        }
662
663        // Re-baseline the urgent-revocation sets against the state just produced,
664        // so the next off-cycle check only fires on revocations newer than this
665        // scheduled pass.
666        if any_urgent {
667            for ca in &config.ca {
668                if ca.urgent_revocation && ca.source.is_some() {
669                    if let Ok(set) = hoike_sign::revoked_serials_for_scope(ca, &sources) {
670                        known_revoked.insert(ca.label.clone(), set);
671                    }
672                }
673            }
674        }
675    }
676}
677
678/// One off-cycle urgent-revocation pass: for each opted-in CA, snapshot the
679/// source, diff against the last-known revoked set, and — if new revocations
680/// appeared — produce and reload an off-cycle bundle for just that scope, then
681/// announce it (generation + explicit urgent notice) over gossip.
682async fn urgent_revocation_pass(
683    state: &hoike_server::AppState,
684    config: &hoike_core::Config,
685    ctx: &Arc<hoike_server::SignerContext>,
686    gossip: &Option<Arc<hoike_gossip::GossipNode>>,
687    known_revoked: &mut std::collections::HashMap<String, std::collections::BTreeSet<Vec<u8>>>,
688) {
689    let sources = ctx.sources.lock().await;
690    for ca in &config.ca {
691        if !(ca.urgent_revocation && ca.source.is_some()) {
692            continue;
693        }
694        let current = match hoike_sign::revoked_serials_for_scope(ca, &sources) {
695            Ok(set) => set,
696            Err(e) => {
697                warn!(ca = ca.label, error = %e, "urgent-revocation snapshot failed");
698                continue;
699            }
700        };
701        let previous = known_revoked.entry(ca.label.clone()).or_default();
702        let new_count = current.difference(previous).count();
703        if new_count == 0 {
704            continue;
705        }
706
707        // Newly-revoked serials detected — produce an off-cycle bundle for this
708        // scope. Prepare and reload its signing material first, mirroring the
709        // scheduled path, so a bad key/cert preserves the active generation.
710        if let Err(e) = prepare_signer_material(ca).await {
711            error!(ca = ca.label, error = %e, "urgent: signing material rejected");
712            continue;
713        }
714        if let Err(e) = state.reload_live_signer(ca) {
715            error!(ca = ca.label, error = %e, "urgent: live material reload failed");
716            continue;
717        }
718
719        let gen_start = std::time::Instant::now();
720        match hoike_sign::sign_and_write_scope(config, &sources, ca) {
721            Ok(signed) => {
722                hoike_server::obs::record_signer_generation(
723                    &signed.label,
724                    gen_start.elapsed().as_secs_f64(),
725                );
726                hoike_server::obs::audit!(
727                    event = "signer_generation",
728                    ca = %signed.label,
729                    trigger = "urgent",
730                    epoch = signed.epoch,
731                    entry_count = signed.entry_count,
732                    new_revocations = new_count,
733                    "produced off-cycle bundle on new revocation"
734                );
735                // Commit the new baseline only after a successful production.
736                *previous = current;
737                if let Err(e) = state.responder.reload() {
738                    error!(ca = ca.label, error = %e, "urgent: reload failed after production");
739                } else if let Some(g) = gossip.as_ref() {
740                    hoike_server::announce_bundle_scopes(g, &signed.bytes).await;
741                    match hoike_sign::decode_issuer_key(ca) {
742                        Ok(key_bytes) => {
743                            use sha2::Digest as _;
744                            let ikh = sha2::Sha256::digest(&key_bytes).to_vec();
745                            if let Err(e) = g
746                                .announce_urgent_revocation(
747                                    hoike_sign::COMBINED_PRODUCER_ID.to_string(),
748                                    ikh,
749                                    signed.epoch,
750                                )
751                                .await
752                            {
753                                warn!(ca = ca.label, error = %e, "urgent: gossip broadcast failed");
754                            }
755                        }
756                        Err(e) => {
757                            warn!(ca = ca.label, error = %e, "urgent: issuer key hash unavailable")
758                        }
759                    }
760                }
761            }
762            Err(e) => {
763                hoike_server::obs::audit!(
764                    event = "signer_generation_failed",
765                    ca = %ca.label,
766                    trigger = "urgent",
767                    error = %e,
768                    "off-cycle production failed"
769                );
770                error!(ca = ca.label, error = %e, "urgent: production failed");
771            }
772        }
773    }
774}
775
776fn decode_b64_field(
777    b64: &Option<String>,
778    field_name: &str,
779    ca_label: &str,
780    fallback: &str,
781) -> std::result::Result<Vec<u8>, String> {
782    match b64 {
783        Some(val) => {
784            use base64::Engine;
785            base64::engine::general_purpose::STANDARD
786                .decode(val)
787                .map_err(|e| format!("CA '{}': invalid base64 in {}: {e}", ca_label, field_name))
788        }
789        None => Ok(fallback.as_bytes().to_vec()),
790    }
791}
792
793fn load_live_signer(
794    ca: &hoike_core::config::CaConfig,
795) -> std::result::Result<hoike_server::LiveSignerState, String> {
796    hoike_server::LiveSignerState::from_config(ca)
797}
798
799fn run_query(
800    url: String,
801    serial_hex: String,
802    issuer_name_b64: String,
803    issuer_key_b64: String,
804    prefer: Option<String>,
805) {
806    use base64::Engine;
807    use der::{Decode, Encode, asn1::OctetString};
808    use sha2::{Digest, Sha256};
809
810    let issuer_name_der = base64::engine::general_purpose::STANDARD
811        .decode(&issuer_name_b64)
812        .unwrap_or_else(|e| {
813            eprintln!("Invalid issuer_name_b64: {e}");
814            std::process::exit(1);
815        });
816    let issuer_key_bytes = base64::engine::general_purpose::STANDARD
817        .decode(&issuer_key_b64)
818        .unwrap_or_else(|e| {
819            eprintln!("Invalid issuer_key_b64: {e}");
820            std::process::exit(1);
821        });
822    let serial_bytes = hex::decode(&serial_hex).unwrap_or_else(|e| {
823        eprintln!("Invalid serial hex: {e}");
824        std::process::exit(1);
825    });
826
827    let sha256_oid = const_oid::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
828    let name_hash = Sha256::digest(&issuer_name_der);
829    let key_hash = Sha256::digest(&issuer_key_bytes);
830
831    let cert_id = x509_ocsp::CertId {
832        hash_algorithm: spki::AlgorithmIdentifierOwned {
833            oid: sha256_oid,
834            parameters: Some(der::asn1::Any::from(der::asn1::Null)),
835        },
836        issuer_name_hash: OctetString::new(name_hash.to_vec()).unwrap(),
837        issuer_key_hash: OctetString::new(key_hash.to_vec()).unwrap(),
838        serial_number: x509_cert::serial_number::SerialNumber::new(&serial_bytes).unwrap_or_else(
839            |e| {
840                eprintln!("Invalid serial: {e}");
841                std::process::exit(1);
842            },
843        ),
844    };
845
846    let request_item = x509_ocsp::Request {
847        req_cert: cert_id,
848        single_request_extensions: None,
849    };
850
851    let mut request_extensions = Vec::new();
852
853    if let Some(prefer_str) = &prefer {
854        let pref_ext_der = build_preferred_sig_algs_extension(prefer_str);
855        let pref_oid = const_oid::ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.48.1.8");
856        request_extensions.push(x509_cert::ext::Extension {
857            extn_id: pref_oid,
858            critical: false,
859            extn_value: OctetString::new(pref_ext_der).unwrap(),
860        });
861    }
862
863    let tbs = x509_ocsp::TbsRequest {
864        version: Default::default(),
865        requestor_name: None,
866        request_list: vec![request_item],
867        request_extensions: if request_extensions.is_empty() {
868            None
869        } else {
870            Some(request_extensions)
871        },
872    };
873    let ocsp_request = x509_ocsp::OcspRequest {
874        tbs_request: tbs,
875        optional_signature: None,
876    };
877
878    let request_der = ocsp_request.to_der().unwrap_or_else(|e| {
879        eprintln!("Failed to encode OcspRequest: {e}");
880        std::process::exit(1);
881    });
882
883    println!("Sending OCSP request to {url}");
884    println!("  Serial:    {serial_hex}");
885    println!("  Request:   {} bytes", request_der.len());
886    if let Some(p) = &prefer {
887        println!("  Prefer:    {p}");
888    }
889
890    let response = ureq::post(&url)
891        .header("Content-Type", "application/ocsp-request")
892        .send(&request_der);
893
894    match response {
895        Ok(resp) => {
896            let status = resp.status();
897            let body = resp.into_body().read_to_vec().unwrap_or_else(|e| {
898                eprintln!("Failed to read response body: {e}");
899                std::process::exit(1);
900            });
901
902            println!("\n── Response ──");
903            println!("  HTTP status: {status}");
904            println!("  Body size:   {} bytes", body.len());
905
906            match x509_ocsp::OcspResponse::from_der(&body) {
907                Ok(ocsp_resp) => {
908                    println!("  OCSP status: {:?}", ocsp_resp.response_status);
909                    if let Some(resp_bytes) = &ocsp_resp.response_bytes {
910                        match x509_ocsp::BasicOcspResponse::from_der(resp_bytes.response.as_bytes())
911                        {
912                            Ok(basic) => {
913                                let alg_oid = basic.signature_algorithm.oid.to_string();
914                                let sig_len = basic.signature.raw_bytes().len();
915                                let alg_name = match alg_oid.as_str() {
916                                    "1.2.840.10045.4.3.2" => "ecdsa-p256-sha256",
917                                    "2.16.840.1.101.3.4.3.17" => "ml-dsa-44",
918                                    "2.16.840.1.101.3.4.3.18" => "ml-dsa-65",
919                                    "2.16.840.1.101.3.4.3.19" => "ml-dsa-87",
920                                    other => other,
921                                };
922                                println!("  Algorithm:   {alg_name}");
923                                println!("  Signature:   {sig_len} bytes");
924                                let resp_count = basic.tbs_response_data.responses.len();
925                                println!("  Responses:   {resp_count}");
926                            }
927                            Err(e) => {
928                                eprintln!("  Failed to parse BasicOcspResponse: {e}");
929                            }
930                        }
931                    }
932                }
933                Err(e) => {
934                    eprintln!("  Failed to parse OcspResponse: {e}");
935                    eprintln!("  Raw (hex): {}", hex::encode(&body));
936                }
937            }
938        }
939        Err(e) => {
940            eprintln!("HTTP request failed: {e}");
941            std::process::exit(1);
942        }
943    }
944}
945
946/// Build the DER-encoded PreferredSignatureAlgorithms extension value.
947///
948/// Uses the same `#[derive(der::Sequence)]` struct used for parsing in
949/// hoike-core, ensuring encode/decode symmetry. RFC 6960 §4.4.7.1.
950fn build_preferred_sig_algs_extension(prefer_str: &str) -> Vec<u8> {
951    use der::Encode;
952
953    #[derive(der::Sequence)]
954    struct PreferredSignatureAlgorithm {
955        sig_identifier: spki::AlgorithmIdentifierOwned,
956    }
957
958    let alg_ids: Vec<PreferredSignatureAlgorithm> = prefer_str
959        .split(',')
960        .map(|alg| {
961            let (oid_str, params) = match alg.trim() {
962                // ECDSA-with-SHA256 requires parameters = NULL per RFC 5754 §3.2
963                "ecdsa-p256" => (
964                    "1.2.840.10045.4.3.2",
965                    Some(der::asn1::Any::from(der::asn1::Null)),
966                ),
967                // ML-DSA algorithms: parameters absent per RFC 9881 §9
968                "ml-dsa-44" => ("2.16.840.1.101.3.4.3.17", None),
969                "ml-dsa-65" => ("2.16.840.1.101.3.4.3.18", None),
970                "ml-dsa-87" => ("2.16.840.1.101.3.4.3.19", None),
971                other => {
972                    eprintln!("Unknown algorithm for --prefer: {other}");
973                    std::process::exit(1);
974                }
975            };
976            PreferredSignatureAlgorithm {
977                sig_identifier: spki::AlgorithmIdentifierOwned {
978                    oid: const_oid::ObjectIdentifier::new_unwrap(oid_str),
979                    parameters: params,
980                },
981            }
982        })
983        .collect();
984
985    alg_ids.to_der().unwrap_or_else(|e| {
986        eprintln!("Failed to encode PreferredSignatureAlgorithms: {e}");
987        std::process::exit(1);
988    })
989}
990
991fn run_import(bundle_path: PathBuf, config_path: PathBuf, force: bool) {
992    let config = match hoike_core::Config::from_file(&config_path) {
993        Ok(c) => c,
994        Err(e) => {
995            eprintln!("Failed to load config from {}: {e}", config_path.display());
996            std::process::exit(1);
997        }
998    };
999
1000    println!("Loading bundle: {}", bundle_path.display());
1001    let bundle = match ahu::Bundle::from_file(&bundle_path) {
1002        Ok(b) => b,
1003        Err(e) => {
1004            eprintln!("Failed to load bundle: {e}");
1005            std::process::exit(1);
1006        }
1007    };
1008
1009    println!("Verifying structure...");
1010    match ahu::verify_structure(&bundle) {
1011        Ok(result) => {
1012            if !result.warnings.is_empty() {
1013                for w in &result.warnings {
1014                    eprintln!("  WARNING: {w}");
1015                }
1016            }
1017            println!("  Structure:  OK");
1018        }
1019        Err(e) => {
1020            eprintln!("  Structure:  FAIL — {e}");
1021            std::process::exit(1);
1022        }
1023    }
1024
1025    if !force {
1026        let state_store =
1027            match hoike_core::StateStore::open(&config.storage.state_db.join("state.json")) {
1028                Ok(s) => s,
1029                Err(e) => {
1030                    eprintln!("  Anti-rollback: FAIL — cannot open state store: {e}");
1031                    std::process::exit(1);
1032                }
1033            };
1034        if let Err(e) = state_store.check_rollback(&bundle) {
1035            eprintln!("  Anti-rollback: REJECTED — {e}");
1036            eprintln!("  Use --force to skip this check (e.g., first import into an enclave)");
1037            std::process::exit(1);
1038        }
1039        println!("  Anti-rollback: OK");
1040    } else {
1041        println!("  Anti-rollback: SKIPPED (--force)");
1042    }
1043
1044    let bundle_dir = &config.storage.bundle_dir;
1045    if let Err(e) = std::fs::create_dir_all(bundle_dir) {
1046        eprintln!(
1047            "Failed to create bundle directory {}: {e}",
1048            bundle_dir.display()
1049        );
1050        std::process::exit(1);
1051    }
1052
1053    let dest_filename = bundle_path
1054        .file_name()
1055        .unwrap_or_else(|| std::ffi::OsStr::new("imported.ahu"));
1056    let dest_path = bundle_dir.join(dest_filename);
1057
1058    if let Err(e) = std::fs::copy(&bundle_path, &dest_path) {
1059        eprintln!("Failed to copy bundle to {}: {e}", dest_path.display());
1060        std::process::exit(1);
1061    }
1062
1063    println!(
1064        "\n  Imported {} → {}",
1065        bundle_path.display(),
1066        dest_path.display()
1067    );
1068    println!("  Producer:  {}", bundle.manifest.producer_id);
1069    println!("  Entries:   {}", bundle.manifest.entry_count);
1070    println!("  Scopes:    {}", bundle.manifest.ca_scopes.len());
1071    for (i, scope) in bundle.manifest.ca_scopes.iter().enumerate() {
1072        println!("    [{}] epoch={}", i, scope.epoch);
1073    }
1074    println!(
1075        "\n  To serve: hoike serve --config {}",
1076        config_path.display()
1077    );
1078    println!("  To reload a running server: send SIGHUP (not yet implemented)");
1079}
1080
1081fn run_check(config_path: PathBuf) {
1082    let config = match hoike_core::Config::from_file(&config_path) {
1083        Ok(c) => {
1084            println!("  Config:    OK ({})", config_path.display());
1085            c
1086        }
1087        Err(e) => {
1088            eprintln!("  Config:    FAIL — {e}");
1089            std::process::exit(1);
1090        }
1091    };
1092
1093    println!("  Mode:      {}", config.server.mode);
1094    println!("  Listen:    {}", config.server.listen);
1095    println!("  Bundle:    {}", config.storage.bundle_dir.display());
1096
1097    if let Err(e) = config.validate_transport(cfg!(feature = "tls")) {
1098        eprintln!("  Mode:      FAIL — {e}");
1099        std::process::exit(1);
1100    }
1101
1102    // Check responder certificate expiry for each CA
1103    for ca in &config.ca {
1104        if let Some(cert_path) = &ca.responder_cert {
1105            match std::fs::read(cert_path) {
1106                Ok(cert_der) => match hoike_sign::format_cert_info(&cert_der) {
1107                    Ok(info) => {
1108                        println!("\n  ── Responder Cert ({}) ──", ca.label);
1109                        println!("    Subject:  {}", info.subject);
1110                        println!("    Issuer:   {}", info.issuer);
1111                        println!("    Days remaining: {}", info.days_remaining);
1112                        println!(
1113                            "    OCSP Signing EKU: {}",
1114                            if info.has_ocsp_signing_eku {
1115                                "yes"
1116                            } else {
1117                                "NOT FOUND — clients may reject"
1118                            }
1119                        );
1120                        if info.is_expired {
1121                            eprintln!("    STATUS: EXPIRED");
1122                        } else if info.days_remaining <= 30 {
1123                            eprintln!("    WARNING: expires in {} days", info.days_remaining);
1124                        } else {
1125                            println!("    Status:   OK");
1126                        }
1127                    }
1128                    Err(e) => {
1129                        eprintln!("    Cert parse: FAIL — {e}");
1130                    }
1131                },
1132                Err(e) => {
1133                    eprintln!("    Cert read ({})): FAIL — {e}", cert_path.display());
1134                }
1135            }
1136        }
1137    }
1138
1139    // ── Transport / Trusted Channels (NIAP PPCA v2.1) ──
1140    // Surface cleartext management and inter-component channels. The OCSP data
1141    // plane on `server.listen` is intentionally plaintext (responses are
1142    // CMS/signature-authenticated) and is not flagged here.
1143    check_trusted_channels(&config);
1144
1145    match hoike_core::ResponderState::load(config) {
1146        Ok(state) => {
1147            println!("  Bundles:   {}", state.bundle_count());
1148            println!("  Scopes:    {}", state.scope_count());
1149            println!("  Entries:   {}", state.total_entries());
1150            for (label, epoch, completeness) in state.scope_info() {
1151                println!("    [{label}] epoch={epoch} completeness={completeness}");
1152            }
1153            println!("\n  All checks passed.");
1154        }
1155        Err(e) => {
1156            eprintln!("  Bundle:    FAIL — {e}");
1157            std::process::exit(1);
1158        }
1159    }
1160}
1161
1162/// Validate transport-security posture for the management and inter-component
1163/// channels (NIAP PPCA FTP_TRP.1 / FTP_ITC.1). Warnings are advisory; a
1164/// cleartext `forward_to` without the explicit `forward_insecure` escape hatch
1165/// is a hard failure (exit 1), matching how other `hoike check` gates behave.
1166fn check_trusted_channels(config: &hoike_core::Config) {
1167    println!("\n  ── Transport / Trusted Channels (NIAP PPCA) ──");
1168    let mut hard_fail = false;
1169
1170    // Admin trusted path (FTP_TRP.1).
1171    let has_operators = config
1172        .server
1173        .admin
1174        .as_ref()
1175        .is_some_and(|a| !a.operators.is_empty());
1176    match (&config.server.admin_listen, &config.server.admin_tls) {
1177        (None, _) if has_operators => {
1178            eprintln!(
1179                "    WARNING: admin API rides the OCSP port (no admin_listen) — operator \
1180                 credentials and session cookies cross in cleartext. Set server.admin_listen \
1181                 + server.admin_tls for a trusted path (FTP_TRP.1)."
1182            );
1183        }
1184        (Some(addr), None) => {
1185            eprintln!(
1186                "    WARNING: admin_listen ({addr}) has no admin_tls — the management channel \
1187                 is plaintext. Configure server.admin_tls (FCS_TLSS_EXT.1)."
1188            );
1189        }
1190        (Some(addr), Some(_)) => {
1191            println!("    Admin:     TLS on {addr} (FCS_TLSS_EXT.1)");
1192        }
1193        _ => {}
1194    }
1195
1196    // Metrics channel.
1197    match (&config.server.metrics_listen, &config.server.metrics_tls) {
1198        (Some(addr), None) => {
1199            eprintln!(
1200                "    WARNING: metrics_listen ({addr}) has no metrics_tls — Prometheus scrape \
1201                 traffic is plaintext. Bind privately or set server.metrics_tls."
1202            );
1203        }
1204        (Some(addr), Some(_)) => {
1205            println!("    Metrics:   TLS on {addr}");
1206        }
1207        _ => {}
1208    }
1209
1210    // Forward proxy channel (FTP_ITC.1).
1211    for ca in &config.ca {
1212        if let Some(url) = &ca.forward_to {
1213            if url.starts_with("https://") {
1214                println!("    Forward ({}): https (FTP_ITC.1)", ca.label);
1215            } else if ca.forward_insecure {
1216                eprintln!(
1217                    "    WARNING: CA '{}' forwards to cleartext {url} (forward_insecure=true) — \
1218                     lab/testing only.",
1219                    ca.label
1220                );
1221            } else {
1222                eprintln!(
1223                    "    FAIL: CA '{}' forward_to is cleartext ({url}). Use https:// or set \
1224                     forward_insecure=true for lab use (FTP_ITC.1).",
1225                    ca.label
1226                );
1227                hard_fail = true;
1228            }
1229            if ca.forward_ca.is_some() && url.starts_with("https://") {
1230                println!(
1231                    "      note: forward_ca must be installed in the system trust store; \
1232                     per-target custom roots are not yet wired into the forward path."
1233                );
1234            }
1235        }
1236    }
1237
1238    // 389 DS syncrepl channel (FTP_ITC.1).
1239    for ca in &config.ca {
1240        if let Some(hoike_core::config::SourceConfig::DogtagSync {
1241            ldap_url,
1242            bind_password,
1243            bind_password_env,
1244            tls,
1245            ..
1246        }) = &ca.source
1247        {
1248            let has_bind_pw = bind_password.is_some() || bind_password_env.is_some();
1249            match tls.as_str() {
1250                "ldaps" | "starttls" => {
1251                    println!("    Syncrepl ({}): {tls} (FTP_ITC.1)", ca.label);
1252                }
1253                "none" if has_bind_pw => {
1254                    eprintln!(
1255                        "    WARNING: CA '{}' dogtag-sync uses plaintext {ldap_url} with a bind \
1256                         password — the password crosses in cleartext. Set tls=\"ldaps\" or \
1257                         tls=\"starttls\" (FTP_ITC.1).",
1258                        ca.label
1259                    );
1260                }
1261                "none" => {}
1262                other => {
1263                    eprintln!(
1264                        "    FAIL: CA '{}' dogtag-sync tls={other:?} is invalid (expected \
1265                         ldaps|starttls|none).",
1266                        ca.label
1267                    );
1268                    hard_fail = true;
1269                }
1270            }
1271        }
1272    }
1273
1274    // archive_cutoff_secs is only effective when the source supplies each
1275    // certificate's notAfter — that is 389 DS syncrepl. On a CRL-backed CA it is
1276    // a silent no-op (CRLs carry no per-certificate expiry), so warn rather than
1277    // let an operator believe pruning is happening.
1278    for ca in &config.ca {
1279        if ca.archive_cutoff_secs > 0
1280            && !matches!(
1281                &ca.source,
1282                Some(hoike_core::config::SourceConfig::DogtagSync { .. })
1283            )
1284        {
1285            eprintln!(
1286                "    WARNING: CA '{}' sets archive_cutoff_secs={} but its source provides no \
1287                 per-certificate notAfter (only 389 DS syncrepl does) — pruning has no effect.",
1288                ca.label, ca.archive_cutoff_secs
1289            );
1290        }
1291    }
1292
1293    // Gossip message authentication (FPT_ITT.1).
1294    if let Some(g) = &config.gossip {
1295        if g.enabled {
1296            match (&g.identity_key, g.peer_identities.is_empty()) {
1297                (Some(_), false) => {
1298                    println!(
1299                        "    Gossip:    signed + enforced ({} peer key(s)) (FPT_ITT.1)",
1300                        g.peer_identities.len()
1301                    );
1302                }
1303                (Some(_), true) => {
1304                    eprintln!(
1305                        "    WARNING: gossip signs outbound messages (identity_key set) but \
1306                         peer_identities is empty — verification is permissive, so unsigned/forged \
1307                         messages from peers are still accepted. Populate gossip.peer_identities to \
1308                         enforce (FPT_ITT.1)."
1309                    );
1310                }
1311                (None, false) => {
1312                    eprintln!(
1313                        "    WARNING: gossip.peer_identities is set but this node has no identity_key — \
1314                         it enforces inbound signatures but broadcasts its own messages unsigned, \
1315                         which enforcing peers will drop. Set gossip.identity_key (FPT_ITT.1)."
1316                    );
1317                }
1318                (None, true) => {
1319                    eprintln!(
1320                        "    WARNING: gossip is enabled but unauthenticated (no identity_key / \
1321                         peer_identities) — membership and generation messages are unsigned (FPT_ITT.1)."
1322                    );
1323                }
1324            }
1325        }
1326    }
1327
1328    if hard_fail {
1329        std::process::exit(1);
1330    }
1331}
1332
1333#[allow(clippy::too_many_arguments)]
1334fn run_sign(
1335    ca_label: String,
1336    crl_path: PathBuf,
1337    output: PathBuf,
1338    issuer_path: Option<PathBuf>,
1339    epoch: u64,
1340    certid_compat: String,
1341    good_serials: Option<PathBuf>,
1342    sig_alg: String,
1343    issuer_name_b64: Option<String>,
1344    issuer_key_b64: Option<String>,
1345    signing_key_path: Option<PathBuf>,
1346    seal_key_path: Option<PathBuf>,
1347    dual_alg: Option<String>,
1348    pq_signing_key_path: Option<PathBuf>,
1349    demo_key: bool,
1350) {
1351    use hoike_sign::{
1352        CaIdentity, CertIdCompat, CertificateStatus, CrlSource, GenerationConfig, RevocationSource,
1353    };
1354
1355    // Resolve signing key — require explicit --signing-key or --demo-key
1356    if signing_key_path.is_none() && !demo_key {
1357        eprintln!(
1358            "Error: no signing key specified.\n\n\
1359             Provide one of:\n  \
1360             --signing-key PATH   PKCS#8 PEM or DER key file\n  \
1361             --demo-key           Ephemeral key for testing only (NOT FOR PRODUCTION)\n"
1362        );
1363        std::process::exit(1);
1364    }
1365
1366    let compat = CertIdCompat::parse(&certid_compat).unwrap_or_else(|e| {
1367        eprintln!("{e}");
1368        std::process::exit(1);
1369    });
1370
1371    let crl_data = std::fs::read(&crl_path).unwrap_or_else(|e| {
1372        eprintln!("Failed to read CRL from {}: {e}", crl_path.display());
1373        std::process::exit(1);
1374    });
1375
1376    let source = if crl_data.starts_with(b"-----BEGIN") {
1377        let pem = String::from_utf8(crl_data).unwrap_or_else(|e| {
1378            eprintln!("CRL file is not valid UTF-8: {e}");
1379            std::process::exit(1);
1380        });
1381        CrlSource::from_pem(&pem).unwrap_or_else(|e| {
1382            eprintln!("Failed to parse PEM CRL: {e}");
1383            std::process::exit(1);
1384        })
1385    } else {
1386        CrlSource::from_der(crl_data).unwrap_or_else(|e| {
1387            eprintln!("Failed to parse DER CRL: {e}");
1388            std::process::exit(1);
1389        })
1390    };
1391
1392    let issuer_bytes = issuer_path
1393        .as_ref()
1394        .and_then(|p| std::fs::read(p).ok())
1395        .unwrap_or_else(|| {
1396            eprintln!(
1397                "--issuer must name a readable authorized CRL issuer certificate (PEM or DER)"
1398            );
1399            std::process::exit(1);
1400        });
1401    let issuer_cert = hoike_sign::rotation::parse_certificate(&issuer_bytes).unwrap_or_else(|e| {
1402        eprintln!("Invalid issuer certificate: {e}");
1403        std::process::exit(1);
1404    });
1405    let source = source
1406        .with_issuer_certificate(&issuer_bytes)
1407        .unwrap_or_else(|e| {
1408            eprintln!("CRL authentication setup failed: {e}");
1409            std::process::exit(1);
1410        });
1411    use base64::Engine as _;
1412    use der::Encode as _;
1413    let issuer_name_b64 = issuer_name_b64.or_else(|| {
1414        Some(
1415            base64::engine::general_purpose::STANDARD.encode(
1416                issuer_cert
1417                    .tbs_certificate
1418                    .subject
1419                    .to_der()
1420                    .expect("valid issuer name"),
1421            ),
1422        )
1423    });
1424    let issuer_key_b64 = issuer_key_b64.or_else(|| {
1425        Some(
1426            base64::engine::general_purpose::STANDARD.encode(
1427                issuer_cert
1428                    .tbs_certificate
1429                    .subject_public_key_info
1430                    .subject_public_key
1431                    .raw_bytes(),
1432            ),
1433        )
1434    });
1435
1436    let issuer_name_der = decode_b64_field(
1437        &issuer_name_b64,
1438        "issuer_name_b64",
1439        &ca_label,
1440        &format!("CN={ca_label}"),
1441    )
1442    .unwrap_or_else(|e| {
1443        eprintln!("{e}");
1444        std::process::exit(1);
1445    });
1446    if issuer_name_b64.is_none() {
1447        warn!(
1448            ca = ca_label,
1449            "no --issuer-name-b64 provided — using synthetic placeholder. \
1450             CertID hashes will not match real client requests."
1451        );
1452    }
1453
1454    let issuer_key_bytes = decode_b64_field(
1455        &issuer_key_b64,
1456        "issuer_key_b64",
1457        &ca_label,
1458        &format!("{ca_label}-key"),
1459    )
1460    .unwrap_or_else(|e| {
1461        eprintln!("{e}");
1462        std::process::exit(1);
1463    });
1464
1465    let ca = CaIdentity {
1466        label: ca_label.clone(),
1467        issuer_name_der,
1468        issuer_key_bytes,
1469    };
1470
1471    let mut snapshot = source.snapshot(&ca).unwrap_or_else(|e| {
1472        eprintln!("Failed to snapshot CRL: {e}");
1473        std::process::exit(1);
1474    });
1475
1476    if let Some(good_path) = good_serials {
1477        let content = std::fs::read_to_string(&good_path).unwrap_or_else(|e| {
1478            eprintln!(
1479                "Failed to read good serials from {}: {e}",
1480                good_path.display()
1481            );
1482            std::process::exit(1);
1483        });
1484        for line in content.lines() {
1485            let line = line.trim();
1486            if line.is_empty() || line.starts_with('#') {
1487                continue;
1488            }
1489            match hex::decode(line) {
1490                Ok(serial) => {
1491                    snapshot
1492                        .entries
1493                        .entry(serial)
1494                        .or_insert(CertificateStatus::Good);
1495                }
1496                Err(e) => {
1497                    eprintln!("Warning: invalid hex serial '{line}': {e}");
1498                }
1499            }
1500        }
1501    }
1502
1503    info!(
1504        ca = ca_label,
1505        revoked = snapshot.entries.values().filter(|s| matches!(s, CertificateStatus::Revoked { .. })).count(),
1506        good = snapshot.entries.values().filter(|s| matches!(s, CertificateStatus::Good)).count(),
1507        sig_alg = %sig_alg,
1508        "snapshot loaded"
1509    );
1510
1511    let config = GenerationConfig {
1512        producer_id: "hoike-cli".into(),
1513        epoch,
1514        certid_compat: compat,
1515        ..Default::default()
1516    };
1517
1518    // Seal key resolution: --seal-key > ECDSA signing key fallback > demo key.
1519    // In production, the seal key should be separate (per ahu spec §2.3).
1520    let is_ml_dsa = matches!(sig_alg.as_str(), "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87");
1521    let cli_seal_key: hoike_sign::SealKey = if let Some(seal_path) = &seal_key_path {
1522        let ecdsa_key = hoike_sign::load_ecdsa_p256_key(seal_path).unwrap_or_else(|e| {
1523            eprintln!("Failed to load seal key from {}: {e}", seal_path.display());
1524            std::process::exit(1);
1525        });
1526        hoike_sign::SealKey::EcdsaP256(ecdsa_key)
1527    } else if !is_ml_dsa {
1528        if let Some(key_path) = &signing_key_path {
1529            warn!("using OCSP signing key as seal key — provide --seal-key for production");
1530            let ecdsa_key = hoike_sign::load_ecdsa_p256_key(key_path).unwrap_or_else(|e| {
1531                eprintln!("Failed to load seal key: {e}");
1532                std::process::exit(1);
1533            });
1534            hoike_sign::SealKey::EcdsaP256(ecdsa_key)
1535        } else {
1536            hoike_sign::SealKey::EcdsaP256(hoike_sign::demo_ecdsa_p256_key())
1537        }
1538    } else if signing_key_path.is_some() {
1539        warn!(
1540            "ML-DSA signing key cannot be used as P-256 seal key — \
1541             provide --seal-key for production use"
1542        );
1543        hoike_sign::SealKey::EcdsaP256(hoike_sign::demo_ecdsa_p256_key())
1544    } else {
1545        hoike_sign::SealKey::EcdsaP256(hoike_sign::demo_ecdsa_p256_key())
1546    };
1547    let cli_seal_cert = hoike_sign::generate_seal_cert_for_key(&cli_seal_key).unwrap_or_else(|e| {
1548        eprintln!("Failed to generate seal cert: {e}");
1549        std::process::exit(1);
1550    });
1551    let seal_fn = move |m: &[u8]| -> hoike_sign::Result<Vec<u8>> {
1552        hoike_sign::create_cms_seal(m, &cli_seal_key, &cli_seal_cert)
1553    };
1554
1555    let bundle_bytes = if let Some(pq_alg) = &dual_alg {
1556        let disc_pq = match pq_alg.as_str() {
1557            "ml-dsa-44" => ahu::ALG_DISC_ML_DSA_44,
1558            "ml-dsa-65" => ahu::ALG_DISC_ML_DSA_65,
1559            "ml-dsa-87" => ahu::ALG_DISC_ML_DSA_87,
1560            other => {
1561                eprintln!("Unknown --dual-alg: {other}");
1562                std::process::exit(1);
1563            }
1564        };
1565        let mut ecdsa_signer = if let Some(key_path) = &signing_key_path {
1566            hoike_sign::load_ecdsa_p256_key(key_path).unwrap_or_else(|e| {
1567                eprintln!("Failed to load classical signing key: {e}");
1568                std::process::exit(1);
1569            })
1570        } else if demo_key {
1571            warn!("using ephemeral ECDSA demo key — NOT FOR PRODUCTION");
1572            hoike_sign::demo_ecdsa_p256_key()
1573        } else {
1574            eprintln!("--dual-alg requires --signing-key (classical) or --demo-key");
1575            std::process::exit(1);
1576        };
1577        let mut pq_signer = if let Some(pq_path) = &pq_signing_key_path {
1578            let v = hoike_sign::load_ml_dsa_key(pq_path).unwrap_or_else(|e| {
1579                eprintln!("Failed to load PQ signing key: {e}");
1580                std::process::exit(1);
1581            });
1582            if v.algorithm_name() != pq_alg.as_str() {
1583                eprintln!(
1584                    "PQ key is {} but --dual-alg is {}",
1585                    v.algorithm_name(), pq_alg
1586                );
1587                std::process::exit(1);
1588            }
1589            v
1590        } else if demo_key {
1591            warn!("using ephemeral ML-DSA demo key — NOT FOR PRODUCTION");
1592            hoike_sign::MlDsaSignerVariant::demo(pq_alg).unwrap_or_else(|e| {
1593                eprintln!("{e}");
1594                std::process::exit(1);
1595            })
1596        } else {
1597            eprintln!("--dual-alg requires --pq-signing-key or --demo-key");
1598            std::process::exit(1);
1599        };
1600        match &mut pq_signer {
1601            hoike_sign::MlDsaSignerVariant::MlDsa44(s) => {
1602                hoike_sign::produce_dual_bundle::<_, p256::ecdsa::DerSignature, _, hoike_sign::MlDsaSignatureBytes>(
1603                    &ca, &snapshot, &config, &mut ecdsa_signer, s, disc_pq, seal_fn, None, None,
1604                )
1605            }
1606            hoike_sign::MlDsaSignerVariant::MlDsa65(s) => {
1607                hoike_sign::produce_dual_bundle::<_, p256::ecdsa::DerSignature, _, hoike_sign::MlDsaSignatureBytes>(
1608                    &ca, &snapshot, &config, &mut ecdsa_signer, s, disc_pq, seal_fn, None, None,
1609                )
1610            }
1611            hoike_sign::MlDsaSignerVariant::MlDsa87(s) => {
1612                hoike_sign::produce_dual_bundle::<_, p256::ecdsa::DerSignature, _, hoike_sign::MlDsaSignatureBytes>(
1613                    &ca, &snapshot, &config, &mut ecdsa_signer, s, disc_pq, seal_fn, None, None,
1614                )
1615            }
1616        }
1617    } else { match sig_alg.as_str() {
1618        "ecdsa-p256" => {
1619            let mut signer = if let Some(key_path) = &signing_key_path {
1620                hoike_sign::load_ecdsa_p256_key(key_path).unwrap_or_else(|e| {
1621                    eprintln!("Failed to load signing key: {e}");
1622                    std::process::exit(1);
1623                })
1624            } else if demo_key {
1625                warn!("using ephemeral ECDSA demo key — NOT FOR PRODUCTION");
1626                hoike_sign::demo_ecdsa_p256_key()
1627            } else {
1628                eprintln!("No signing key provided. Use --signing-key <path> for a PKCS#8 key file, or --demo-key for testing.");
1629                std::process::exit(1);
1630            };
1631            hoike_sign::produce_bundle::<_, p256::ecdsa::DerSignature>(
1632                &ca,
1633                &snapshot,
1634                &config,
1635                &mut signer,
1636                seal_fn,
1637                None,
1638            )
1639        }
1640        "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87" => {
1641            let mut variant = if let Some(key_path) = &signing_key_path {
1642                let v = hoike_sign::load_ml_dsa_key(key_path).unwrap_or_else(|e| {
1643                    eprintln!("Failed to load ML-DSA signing key: {e}");
1644                    std::process::exit(1);
1645                });
1646                if v.algorithm_name() != sig_alg {
1647                    eprintln!(
1648                        "Key algorithm mismatch: key is {} but --sig-alg is {}",
1649                        v.algorithm_name(),
1650                        sig_alg
1651                    );
1652                    std::process::exit(1);
1653                }
1654                info!(
1655                    ca = ca_label,
1656                    key = %key_path.display(),
1657                    alg = v.algorithm_name(),
1658                    "using file-based ML-DSA signing key"
1659                );
1660                v
1661            } else if demo_key {
1662                warn!("using ephemeral ML-DSA demo key — NOT FOR PRODUCTION");
1663                hoike_sign::MlDsaSignerVariant::demo(&sig_alg).unwrap_or_else(|e| {
1664                    eprintln!("{e}");
1665                    std::process::exit(1);
1666                })
1667            } else {
1668                eprintln!(
1669                    "No signing key provided. Use --signing-key <path> for a PKCS#8 key file, \
1670                     or --demo-key for testing."
1671                );
1672                std::process::exit(1);
1673            };
1674            variant.sign_bundle(&ca, &snapshot, &config, seal_fn, None)
1675        }
1676        other => {
1677            eprintln!(
1678                "Unknown sig_alg: {other} (expected: ecdsa-p256, ml-dsa-44, ml-dsa-65, ml-dsa-87)"
1679            );
1680            std::process::exit(1);
1681        }
1682    } }
1683    .unwrap_or_else(|e| {
1684        eprintln!("Failed to produce bundle: {e}");
1685        std::process::exit(1);
1686    });
1687
1688    hoike_sign::orchestrate::write_bundle_atomic(&output, &bundle_bytes).unwrap_or_else(|e| {
1689        eprintln!("Failed to write bundle to {}: {e}", output.display());
1690        std::process::exit(1);
1691    });
1692
1693    let entry_count = ahu::Bundle::from_bytes(&bundle_bytes)
1694        .map(|b| b.manifest.entry_count)
1695        .unwrap_or(0);
1696    let avg_size = if entry_count > 0 {
1697        bundle_bytes.len() / entry_count as usize
1698    } else {
1699        0
1700    };
1701    let sig_overhead = hoike_sign::ml_dsa_signature_size(&sig_alg);
1702
1703    println!("Bundle size: {} bytes", bundle_bytes.len());
1704    println!("  Entries:           {entry_count}");
1705    println!("  Avg response size: {avg_size} bytes");
1706    if sig_overhead > 0 {
1707        println!("  Signature overhead: ~{sig_overhead} bytes ({sig_alg})");
1708    }
1709    println!("  Output:            {}", output.display());
1710}
1711
1712#[cfg(test)]
1713mod rotation_preflight_tests {
1714    use super::*;
1715    use der::{Decode, Encode};
1716    use p256::pkcs8::EncodePrivateKey;
1717
1718    #[tokio::test]
1719    async fn expired_live_certificate_is_renewed_before_startup_and_bad_pair_rejected() {
1720        let dir = tempfile::tempdir().unwrap();
1721        let key = hoike_sign::demo_ecdsa_p256_key();
1722        let valid = hoike_sign::generate_seal_cert(&key).unwrap();
1723        let mut expired = x509_cert::Certificate::from_der(&valid).unwrap();
1724        expired.tbs_certificate.validity.not_after = x509_cert::time::Time::GeneralTime(
1725            der::asn1::GeneralizedTime::from_unix_duration(std::time::Duration::from_secs(
1726                1_700_000_000,
1727            ))
1728            .unwrap(),
1729        );
1730        std::fs::write(
1731            dir.path().join("key.der"),
1732            key.to_pkcs8_der().unwrap().as_bytes(),
1733        )
1734        .unwrap();
1735        std::fs::write(dir.path().join("valid.der"), &valid).unwrap();
1736        std::fs::write(dir.path().join("active.der"), expired.to_der().unwrap()).unwrap();
1737        let config_path = dir.path().join("config.toml");
1738        std::fs::write(
1739            &config_path,
1740            format!(
1741                r#"
1742[server]
1743mode = "combined"
1744[storage]
1745bundle_dir = '{0}'
1746[[ca]]
1747label = "test"
1748nonce_policy = "live"
1749responder_cert = '{0}/active.der'
1750[ca.signing_key]
1751type = "file"
1752path = '{0}/key.der'
1753[ca.key_rotation]
1754renew_before_days = 7
1755rotation_command = "cp '{0}/valid.der' '{0}/active.der'"
1756"#,
1757                dir.path().display()
1758            ),
1759        )
1760        .unwrap();
1761        let config = hoike_core::Config::from_file(&config_path).unwrap();
1762        assert!(load_live_signer(&config.ca[0]).is_err());
1763        prepare_signer_material(&config.ca[0]).await.unwrap();
1764        assert_eq!(std::fs::read(dir.path().join("active.der")).unwrap(), valid);
1765        assert!(load_live_signer(&config.ca[0]).is_ok());
1766        let other = p256::ecdsa::SigningKey::from_bytes((&[13u8; 32]).into()).unwrap();
1767        std::fs::write(
1768            dir.path().join("key.der"),
1769            other.to_pkcs8_der().unwrap().as_bytes(),
1770        )
1771        .unwrap();
1772        assert!(prepare_signer_material(&config.ca[0]).await.is_err());
1773    }
1774}