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 Serve {
21 #[arg(long, default_value = "hoike.toml")]
23 config: PathBuf,
24 },
25 Check {
27 #[arg(long, default_value = "hoike.toml")]
29 config: PathBuf,
30 },
31 Sign {
33 #[arg(long)]
35 ca: String,
36 #[arg(long)]
38 crl: PathBuf,
39 #[arg(short, long, default_value = "output.ahu")]
41 output: PathBuf,
42 #[arg(long)]
44 issuer: Option<PathBuf>,
45 #[arg(long, default_value = "1")]
47 epoch: u64,
48 #[arg(long, default_value = "dual")]
50 certid_compat: String,
51 #[arg(long)]
53 good_serials: Option<PathBuf>,
54 #[arg(long, default_value = "ecdsa-p256")]
56 sig_alg: String,
57 #[arg(long)]
59 issuer_name_b64: Option<String>,
60 #[arg(long)]
62 issuer_key_b64: Option<String>,
63 #[arg(long, conflicts_with = "demo_key")]
65 signing_key: Option<PathBuf>,
66 #[arg(long)]
68 seal_key: Option<PathBuf>,
69 #[arg(long)]
72 dual_alg: Option<String>,
73 #[arg(long)]
75 pq_signing_key: Option<PathBuf>,
76 #[arg(long)]
78 demo_key: bool,
79 },
80 Query {
82 #[arg(long)]
84 url: String,
85 #[arg(long)]
87 serial: String,
88 #[arg(long)]
90 issuer_name_b64: String,
91 #[arg(long)]
93 issuer_key_b64: String,
94 #[arg(long)]
96 prefer: Option<String>,
97 },
98 Import {
100 #[arg(long)]
102 bundle: PathBuf,
103 #[arg(long, default_value = "hoike.toml")]
105 config: PathBuf,
106 #[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 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 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 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 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 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 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 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 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 }
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 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 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 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
413async 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
453async 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 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
514const 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 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 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 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 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 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
678async 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 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 *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
946fn 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-p256" => (
964 "1.2.840.10045.4.3.2",
965 Some(der::asn1::Any::from(der::asn1::Null)),
966 ),
967 "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 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 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
1162fn check_trusted_channels(config: &hoike_core::Config) {
1167 println!("\n ── Transport / Trusted Channels (NIAP PPCA) ──");
1168 let mut hard_fail = false;
1169
1170 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 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 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 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 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 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 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 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}