1use crate::backend::BackendError;
33use crate::catalog::{Class, Engine, GenerateSpec, KeyAlgorithm, KeyEntry, MissingPolicy};
34use crate::manager::{BackendManager, ManagerError, Routed};
35use basil_proto::{AeadAlgorithm, KeyType};
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct ReconcileSummary {
40 pub present: usize,
42 pub generated: usize,
44 pub warned: usize,
46}
47
48#[derive(Debug, thiserror::Error)]
54pub enum ReconcileError {
55 #[error("required key(s) absent from backend: {}", .0.join(", "))]
58 RequiredMissing(Vec<String>),
59
60 #[error("probing key `{key}` failed (backend unreachable or rejecting): {source}")]
65 Probe {
66 key: String,
68 source: BackendError,
70 },
71
72 #[error("generating key `{key}` failed: {source}")]
74 Generate {
75 key: String,
77 source: ManagerError,
79 },
80}
81
82enum Existence {
85 Present,
86 Absent,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum KeyStatus {
93 Present,
95 Missing(MissingPolicy),
100}
101
102#[derive(Debug, Clone)]
104pub struct KeyCheck {
105 pub name: String,
107 pub status: KeyStatus,
109}
110
111#[derive(Debug, Clone, Default)]
119pub struct CheckReport {
120 pub keys: Vec<KeyCheck>,
122}
123
124impl CheckReport {
125 #[must_use]
127 pub fn present_count(&self) -> usize {
128 self.keys
129 .iter()
130 .filter(|k| k.status == KeyStatus::Present)
131 .count()
132 }
133
134 pub fn missing(&self) -> impl Iterator<Item = (&str, MissingPolicy)> {
136 self.keys.iter().filter_map(|k| match k.status {
137 KeyStatus::Missing(policy) => Some((k.name.as_str(), policy)),
138 KeyStatus::Present => None,
139 })
140 }
141
142 #[must_use]
147 pub fn required_missing(&self) -> Vec<&str> {
148 self.missing()
149 .filter(|(_, policy)| *policy == MissingPolicy::Error)
150 .map(|(name, _)| name)
151 .collect()
152 }
153
154 #[must_use]
158 pub fn should_fail_required(&self) -> bool {
159 self.missing()
160 .any(|(_, policy)| policy == MissingPolicy::Error)
161 }
162}
163
164impl BackendManager {
165 pub async fn reconcile(&self) -> Result<ReconcileSummary, ReconcileError> {
180 use crate::catalog::MissingPolicy;
181
182 let mut summary = ReconcileSummary::default();
183 let mut required_missing: Vec<String> = Vec::new();
184
185 let keys: Vec<(String, KeyEntry)> = self
188 .keys()
189 .map(|(name, entry)| (name.clone(), entry.clone()))
190 .collect();
191
192 for (name, entry) in &keys {
193 match self.probe(name).await? {
194 Existence::Present => summary.present += 1,
195 Existence::Absent => match entry.missing {
196 MissingPolicy::Error => {
197 required_missing.push(name.clone());
198 }
199 MissingPolicy::Warn => {
200 tracing::warn!(
201 key = %name,
202 "catalog key absent from backend (missing=warn); ops will fail until it exists"
203 );
204 summary.warned += 1;
205 }
206 MissingPolicy::Generate => {
207 self.generate_missing(name, entry).await?;
208 tracing::info!(key = %name, "generated absent key (missing=generate)");
209 summary.generated += 1;
210 }
211 },
212 }
213 }
214
215 if !required_missing.is_empty() {
216 required_missing.sort();
217 return Err(ReconcileError::RequiredMissing(required_missing));
218 }
219 Ok(summary)
220 }
221
222 pub async fn check(&self) -> Result<CheckReport, ReconcileError> {
241 let mut report = CheckReport::default();
243 for (name, entry) in self.keys() {
244 let status = match self.probe(name).await? {
245 Existence::Present => KeyStatus::Present,
246 Existence::Absent => KeyStatus::Missing(entry.missing),
247 };
248 report.keys.push(KeyCheck {
249 name: name.clone(),
250 status,
251 });
252 }
253 Ok(report)
254 }
255
256 async fn probe(&self, name: &str) -> Result<Existence, ReconcileError> {
265 let routed = self.resolve(name).map_err(|source| ReconcileError::Probe {
266 key: name.to_string(),
267 source: match source {
270 ManagerError::Backend(e) => e,
271 other => BackendError::Backend(other.to_string()),
272 },
273 })?;
274
275 let result = match (routed.class(), routed.engine) {
281 (Class::Asymmetric, Engine::Kv2) | (Class::Sealing, _) => {
287 self.probe_materialize_to_use(&routed).await
288 }
289 (Class::Asymmetric | Class::Symmetric, _) => {
290 routed.backend.key_metadata(routed.path()).await.map(|_| ())
291 }
292 (Class::Value | Class::Public, _) => {
293 routed.backend.kv_get(routed.path(), None).await.map(|_| ())
294 }
295 };
296
297 match result {
298 Ok(()) => Ok(Existence::Present),
299 Err(BackendError::KeyNotFound(_)) => Ok(Existence::Absent),
300 Err(source) => Err(ReconcileError::Probe {
301 key: name.to_string(),
302 source,
303 }),
304 }
305 }
306
307 async fn probe_materialize_to_use(&self, routed: &Routed<'_>) -> Result<(), BackendError> {
316 routed.backend.kv_get(routed.path(), None).await?;
318 let public_path = routed.public_path().ok_or_else(|| {
320 BackendError::KeyNotFound(format!(
321 "{} has no public_path; its public half is not provisioned",
322 routed.path()
323 ))
324 })?;
325 routed.backend.kv_get(public_path, None).await?;
326 Ok(())
327 }
328
329 async fn generate_missing(&self, name: &str, entry: &KeyEntry) -> Result<(), ReconcileError> {
333 let to_err = |source| ReconcileError::Generate {
334 key: name.to_string(),
335 source,
336 };
337 let routed = self.resolve(name).map_err(to_err)?;
338 let path = routed.path().to_string();
339
340 match entry.class {
341 Class::Asymmetric if routed.engine == Engine::Kv2 => {
348 return Err(ReconcileError::Generate {
349 key: name.to_string(),
350 source: ManagerError::Backend(BackendError::Backend(format!(
351 "value-store signing key `{name}` (engine=kv2) cannot be \
352 generated in-broker; provision its Ed25519 seed out of band"
353 ))),
354 });
355 }
356 Class::Asymmetric => {
357 let key_type = asym_key_type(name, entry.key_type).map_err(to_err)?;
358 routed
359 .require_mint_key_type("generate", key_type)
360 .map_err(to_err)?;
361 routed
362 .backend
363 .create_named_key(&path, key_type)
364 .await
365 .map_err(ManagerError::from)
366 .map_err(to_err)?;
367 }
368 Class::Symmetric => {
369 let aead = sym_aead(name, entry.key_type).map_err(to_err)?;
370 routed
371 .backend
372 .create_named_aead(&path, aead)
373 .await
374 .map_err(ManagerError::from)
375 .map_err(to_err)?;
376 }
377 Class::Sealing => {
378 return Err(ReconcileError::Generate {
383 key: name.to_string(),
384 source: ManagerError::Backend(BackendError::Backend(format!(
385 "sealing key `{name}` cannot be generated in-broker; \
386 provision its X25519 private out of band"
387 ))),
388 });
389 }
390 Class::Value | Class::Public => {
391 let spec: &GenerateSpec =
395 entry
396 .generate
397 .as_ref()
398 .ok_or_else(|| ReconcileError::Generate {
399 key: name.to_string(),
400 source: ManagerError::Backend(BackendError::Backend(
401 "missing=generate value/public key has no generate recipe".into(),
402 )),
403 })?;
404 for write in self
405 .generated_writes_for_key(name, spec)
406 .await
407 .map_err(to_err)?
408 {
409 let write_route = self.resolve(&write.key_id).map_err(to_err)?;
410 write_route
411 .backend
412 .kv_put(write_route.path(), &write.value)
413 .await
414 .map_err(ManagerError::from)
415 .map_err(to_err)?;
416 }
417 }
418 }
419 Ok(())
420 }
421}
422
423fn asym_key_type(name: &str, key_type: Option<KeyAlgorithm>) -> Result<KeyType, ManagerError> {
427 match key_type {
428 Some(KeyAlgorithm::Ed25519) => Ok(KeyType::Ed25519),
429 Some(KeyAlgorithm::Ed25519Nkey) => Ok(KeyType::Ed25519Nkey),
430 Some(KeyAlgorithm::Rsa2048) => Ok(KeyType::Rsa2048),
431 Some(KeyAlgorithm::EcdsaP256) => Ok(KeyType::EcdsaP256),
432 Some(KeyAlgorithm::EcdsaP384) => Ok(KeyType::EcdsaP384),
433 Some(KeyAlgorithm::EcdsaP521) => Ok(KeyType::EcdsaP521),
434 Some(KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87) => {
440 Err(ManagerError::Backend(BackendError::Backend(format!(
441 "ML-DSA signing key `{name}` is software-custodied; provision it \
442 via the `new_key` provider path, not startup generate"
443 ))))
444 }
445 Some(
446 KeyAlgorithm::Aes256Gcm
447 | KeyAlgorithm::ChaCha20Poly1305
448 | KeyAlgorithm::X25519
449 | KeyAlgorithm::MlKem512
450 | KeyAlgorithm::MlKem768
451 | KeyAlgorithm::MlKem1024,
452 )
453 | None => Err(ManagerError::Backend(BackendError::Backend(format!(
454 "asymmetric key `{name}` has no signing keyType to generate from"
455 )))),
456 }
457}
458
459fn sym_aead(name: &str, key_type: Option<KeyAlgorithm>) -> Result<AeadAlgorithm, ManagerError> {
462 match key_type {
463 Some(KeyAlgorithm::Aes256Gcm) => Ok(AeadAlgorithm::Aes256Gcm),
464 Some(KeyAlgorithm::ChaCha20Poly1305) => Ok(AeadAlgorithm::Chacha20Poly1305),
465 _ => Err(ManagerError::Backend(BackendError::Backend(format!(
466 "symmetric key `{name}` has no AEAD keyType to generate from"
467 )))),
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::backend::{Backend, KeyMetadata, KvValue, NewKey, PublicKey};
475 use crate::catalog::Catalog;
476 use async_trait::async_trait;
477 use std::collections::BTreeMap;
478 use std::sync::Mutex;
479 use std::sync::atomic::{AtomicUsize, Ordering};
480
481 #[derive(Clone, Copy)]
483 enum Probe {
484 Present,
486 Absent,
488 Unreachable,
490 }
491
492 #[derive(Default)]
496 struct Recorder {
497 create_named_key_calls: AtomicUsize,
498 create_named_aead_calls: AtomicUsize,
499 kv_put_calls: AtomicUsize,
500 last_create_path: Mutex<Option<String>>,
501 last_kv_put: Mutex<Option<(String, Vec<u8>)>>,
502 last_aead: Mutex<Option<AeadAlgorithm>>,
503 last_key_type: Mutex<Option<KeyType>>,
504 }
505
506 struct MockBackend {
507 probe: Probe,
508 rec: std::sync::Arc<Recorder>,
509 }
510
511 impl MockBackend {
512 fn new(probe: Probe) -> (Self, std::sync::Arc<Recorder>) {
513 let rec = std::sync::Arc::new(Recorder::default());
514 (
515 Self {
516 probe,
517 rec: rec.clone(),
518 },
519 rec,
520 )
521 }
522 }
523
524 #[async_trait]
525 impl Backend for MockBackend {
526 fn kind(&self) -> &'static str {
527 "mock"
528 }
529
530 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
531 Err(BackendError::Unsupported(
532 "new_key (unused in reconcile tests)",
533 ))
534 }
535
536 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
537 Err(BackendError::Unsupported("public_key"))
538 }
539
540 async fn public_key_with_meta(&self, _key_id: &str) -> Result<PublicKey, BackendError> {
541 Err(BackendError::Unsupported("public_key_with_meta"))
542 }
543
544 async fn key_metadata(&self, _key_id: &str) -> Result<KeyMetadata, BackendError> {
546 match self.probe {
547 Probe::Present => Ok(KeyMetadata {
548 key_type: Some(KeyType::Ed25519),
549 latest_version: 1,
550 }),
551 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
552 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
553 }
554 }
555
556 async fn kv_get(
558 &self,
559 _key_id: &str,
560 _version: Option<u32>,
561 ) -> Result<KvValue, BackendError> {
562 match self.probe {
563 Probe::Present => Ok(KvValue {
564 value: b"present".to_vec(),
565 version: 1,
566 }),
567 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
568 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
569 }
570 }
571
572 async fn kv_get_secret(
575 &self,
576 _key_id: &str,
577 _version: Option<u32>,
578 ) -> Result<crate::backend::KvSecret, BackendError> {
579 match self.probe {
580 Probe::Present => Ok(crate::backend::KvSecret {
581 value: zeroize::Zeroizing::new(b"present".to_vec()),
582 version: 1,
583 }),
584 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
585 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
586 }
587 }
588
589 async fn create_named_key(
590 &self,
591 key_id: &str,
592 key_type: KeyType,
593 ) -> Result<NewKey, BackendError> {
594 self.rec
595 .create_named_key_calls
596 .fetch_add(1, Ordering::SeqCst);
597 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
598 *self.rec.last_key_type.lock().unwrap() = Some(key_type);
599 Ok(NewKey {
600 key_id: key_id.to_string(),
601 public_key: vec![1, 2, 3],
602 })
603 }
604
605 async fn create_named_aead(
606 &self,
607 key_id: &str,
608 aead: AeadAlgorithm,
609 ) -> Result<(), BackendError> {
610 self.rec
611 .create_named_aead_calls
612 .fetch_add(1, Ordering::SeqCst);
613 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
614 *self.rec.last_aead.lock().unwrap() = Some(aead);
615 Ok(())
616 }
617
618 async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
619 self.rec.kv_put_calls.fetch_add(1, Ordering::SeqCst);
620 *self.rec.last_kv_put.lock().unwrap() = Some((key_id.to_string(), value.to_vec()));
621 Ok(1)
622 }
623
624 async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
625 Err(BackendError::Unsupported("sign"))
626 }
627
628 async fn verify(
629 &self,
630 _key_id: &str,
631 _message: &[u8],
632 _signature: &[u8],
633 ) -> Result<bool, BackendError> {
634 Err(BackendError::Unsupported("verify"))
635 }
636 }
637
638 const CATALOG: &str = r#"{
641 "schemaVersion": 1,
642 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
643 "keys": {
644 "req.signer": {
645 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
646 "path": "req-signer", "writable": true, "missing": "error",
647 "description": "a required signing key"
648 },
649 "warn.value": {
650 "class": "value", "backend": "b", "engine": "kv2",
651 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
652 "description": "a warn-on-missing value"
653 },
654 "gen.signer": {
655 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
656 "path": "gen-signer", "writable": true, "missing": "generate",
657 "description": "a generate-on-missing signing key"
658 },
659 "gen.box": {
660 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
661 "path": "gen-box", "writable": true, "missing": "generate",
662 "description": "a generate-on-missing AEAD key"
663 },
664 "gen.value": {
665 "class": "value", "backend": "b", "engine": "kv2",
666 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
667 "generate": { "format": "ascii-printable", "bytes": 24 },
668 "description": "a generate-on-missing value"
669 }
670 }
671 }"#;
672
673 fn parse() -> Catalog {
674 serde_json::from_str(CATALOG).expect("catalog parses")
675 }
676
677 fn manager_with(probe: Probe) -> (BackendManager, std::sync::Arc<Recorder>) {
680 let (backend, rec) = MockBackend::new(probe);
681 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
682 backends.insert("b".into(), Box::new(backend));
683 let mgr = BackendManager::new(parse(), backends).expect("manager constructs");
684 (mgr, rec)
685 }
686
687 #[tokio::test]
688 async fn all_present_is_a_noop_with_present_count() {
689 let (mgr, rec) = manager_with(Probe::Present);
690 let summary = mgr.reconcile().await.expect("all-present reconciles ok");
691 assert_eq!(summary.present, 5);
692 assert_eq!(summary.generated, 0);
693 assert_eq!(summary.warned, 0);
694 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
696 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
697 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
698 }
699
700 #[tokio::test]
701 async fn required_missing_is_fatal_and_reports_the_key() {
702 let (mgr, _rec) = manager_with(Probe::Absent);
705 let err = mgr
706 .reconcile()
707 .await
708 .expect_err("a required-missing key must fail closed");
709 match err {
710 ReconcileError::RequiredMissing(keys) => {
711 assert_eq!(keys, vec!["req.signer".to_string()]);
712 }
713 other => panic!("expected RequiredMissing, got {other:?}"),
714 }
715 }
716
717 #[tokio::test]
718 async fn warn_missing_is_ok_and_counted() {
719 const NO_REQUIRED: &str = r#"{
725 "schemaVersion": 1,
726 "backends": {
727 "b": {
728 "kind": "vault", "addr": "http://127.0.0.1:8200",
729 "engines": ["transit", "kv2"], "capabilities": [],
730 "mintKeyTypes": ["ed25519"]
731 }
732 },
733 "keys": {
734 "warn.value": {
735 "class": "value", "backend": "b", "engine": "kv2",
736 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
737 "description": "a warn-on-missing value"
738 }
739 }
740 }"#;
741 let cat: Catalog = serde_json::from_str(NO_REQUIRED).expect("parses");
742 let (backend, _rec) = MockBackend::new(Probe::Absent);
743 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
744 backends.insert("b".into(), Box::new(backend));
745 let mgr = BackendManager::new(cat, backends).expect("constructs");
746 let summary = mgr.reconcile().await.expect("warn-only reconciles ok");
747 assert_eq!(summary.warned, 1);
748 assert_eq!(summary.present, 0);
749 assert_eq!(summary.generated, 0);
750 }
751
752 #[tokio::test]
753 async fn generate_missing_creates_crypto_and_value_keys() {
754 const GEN_ONLY: &str = r#"{
757 "schemaVersion": 1,
758 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
759 "keys": {
760 "gen.signer": {
761 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
762 "path": "gen-signer", "writable": true, "missing": "generate",
763 "description": "asym generate"
764 },
765 "gen.box": {
766 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
767 "path": "gen-box", "writable": true, "missing": "generate",
768 "description": "sym generate"
769 },
770 "gen.value": {
771 "class": "value", "backend": "b", "engine": "kv2",
772 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
773 "generate": { "format": "ascii-printable", "bytes": 24 },
774 "description": "value generate"
775 }
776 }
777 }"#;
778 let cat: Catalog = serde_json::from_str(GEN_ONLY).expect("parses");
779 let (backend, rec) = MockBackend::new(Probe::Absent);
780 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
781 backends.insert("b".into(), Box::new(backend));
782 let mgr = BackendManager::new(cat, backends).expect("constructs");
783
784 let summary = mgr.reconcile().await.expect("generate reconciles ok");
785 assert_eq!(summary.generated, 3);
786 assert_eq!(summary.present, 0);
787 assert_eq!(summary.warned, 0);
788
789 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
791 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Ed25519));
792 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 1);
794 assert_eq!(
795 *rec.last_aead.lock().unwrap(),
796 Some(AeadAlgorithm::Aes256Gcm)
797 );
798 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 1);
800 let (path, value) = rec.last_kv_put.lock().unwrap().clone().expect("a kv write");
801 assert_eq!(path, "secret/data/gen/value");
802 assert_eq!(value.len(), 24);
803 assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
804 }
805
806 fn generated_asymmetric_catalog(key_type: &str, mint_key_types: &str) -> String {
807 format!(
808 r#"{{
809 "schemaVersion": 1,
810 "backends": {{
811 "b": {{
812 "kind": "vault", "addr": "http://127.0.0.1:8200",
813 "engines": ["transit"], "capabilities": [],
814 "mintKeyTypes": [{mint_key_types}]
815 }}
816 }},
817 "keys": {{
818 "gen.signer": {{
819 "class": "asymmetric", "keyType": "{key_type}", "backend": "b",
820 "path": "gen-signer", "writable": true, "missing": "generate",
821 "description": "generated signer"
822 }}
823 }}
824 }}"#
825 )
826 }
827
828 #[tokio::test]
829 async fn generate_missing_creates_rsa_when_static_preset_allows_it() {
830 let cat: Catalog =
831 serde_json::from_str(&generated_asymmetric_catalog("rsa-2048", r#""rsa-2048""#))
832 .expect("parses");
833 let (backend, rec) = MockBackend::new(Probe::Absent);
834 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
835 backends.insert("b".into(), Box::new(backend));
836 let mgr = BackendManager::new(cat, backends).expect("constructs");
837
838 let summary = mgr.reconcile().await.expect("rsa generate reconciles ok");
839 assert_eq!(summary.generated, 1);
840 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
841 assert_eq!(
842 rec.last_create_path.lock().unwrap().as_deref(),
843 Some("gen-signer")
844 );
845 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Rsa2048));
846 }
847
848 #[tokio::test]
849 async fn generate_missing_creates_ecdsa_p256_when_static_preset_allows_it() {
850 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
851 "ecdsa-p256",
852 r#""ecdsa-p256""#,
853 ))
854 .expect("parses");
855 let (backend, rec) = MockBackend::new(Probe::Absent);
856 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
857 backends.insert("b".into(), Box::new(backend));
858 let mgr = BackendManager::new(cat, backends).expect("constructs");
859
860 let summary = mgr.reconcile().await.expect("ecdsa generate reconciles ok");
861 assert_eq!(summary.generated, 1);
862 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
863 assert_eq!(
864 rec.last_create_path.lock().unwrap().as_deref(),
865 Some("gen-signer")
866 );
867 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::EcdsaP256));
868 }
869
870 #[tokio::test]
871 async fn generate_missing_creates_nkey_when_static_preset_allows_it() {
872 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
873 "ed25519-nkey",
874 r#""ed25519-nkey""#,
875 ))
876 .expect("parses");
877 let (backend, rec) = MockBackend::new(Probe::Absent);
878 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
879 backends.insert("b".into(), Box::new(backend));
880 let mgr = BackendManager::new(cat, backends).expect("constructs");
881
882 let summary = mgr.reconcile().await.expect("nkey generate reconciles ok");
883 assert_eq!(summary.generated, 1);
884 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
885 assert_eq!(
886 rec.last_create_path.lock().unwrap().as_deref(),
887 Some("gen-signer")
888 );
889 assert_eq!(
890 *rec.last_key_type.lock().unwrap(),
891 Some(KeyType::Ed25519Nkey)
892 );
893 }
894
895 #[tokio::test]
896 async fn generate_missing_rejects_key_type_absent_from_static_backend_preset() {
897 const GEN_RSA_UNSUPPORTED: &str = r#"{
898 "schemaVersion": 1,
899 "backends": {
900 "b": {
901 "kind": "vault", "addr": "http://127.0.0.1:8200",
902 "engines": ["transit"], "capabilities": [],
903 "mintKeyTypes": ["ed25519"]
904 }
905 },
906 "keys": {
907 "gen.rsa": {
908 "class": "asymmetric", "keyType": "rsa-2048", "backend": "b",
909 "path": "gen-rsa", "writable": true, "missing": "generate",
910 "description": "rsa generate"
911 }
912 }
913 }"#;
914 let cat: Catalog = serde_json::from_str(GEN_RSA_UNSUPPORTED).expect("parses");
915 let (backend, rec) = MockBackend::new(Probe::Absent);
916 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
917 backends.insert("b".into(), Box::new(backend));
918 let mgr = BackendManager::new(cat, backends).expect("constructs");
919
920 let err = mgr
921 .reconcile()
922 .await
923 .expect_err("rsa is absent from preset");
924 assert!(matches!(
925 err,
926 ReconcileError::Generate {
927 source: ManagerError::UnsupportedKeyType {
928 backend,
929 op: "generate",
930 key_type: KeyType::Rsa2048
931 },
932 ..
933 } if backend == "b"
934 ));
935 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
936 }
937
938 #[tokio::test]
939 async fn kv2_signer_missing_generate_refuses_in_broker() {
940 const KV2_SIGNER: &str = r#"{
946 "schemaVersion": 1,
947 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
948 "keys": {
949 "kv2.signer": {
950 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
951 "path": "secret/data/kv2/signer", "writable": true, "missing": "generate",
952 "description": "a value-store materialize-to-sign key"
953 }
954 }
955 }"#;
956 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
957 let (backend, rec) = MockBackend::new(Probe::Absent);
958 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
959 backends.insert("b".into(), Box::new(backend));
960 let mgr = BackendManager::new(cat, backends).expect("constructs");
961
962 let err = mgr
963 .reconcile()
964 .await
965 .expect_err("a kv2 signing key cannot be generated in-broker");
966 assert!(matches!(err, ReconcileError::Generate { .. }));
967 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
969 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
970 }
971
972 #[tokio::test]
973 async fn kv2_signer_present_probes_kv_not_transit() {
974 const KV2_SIGNER: &str = r#"{
978 "schemaVersion": 1,
979 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
980 "keys": {
981 "kv2.signer": {
982 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
983 "path": "secret/data/kv2/signer",
984 "publicPath": "secret/data/kv2/signer-public",
985 "writable": true, "missing": "error",
986 "description": "a value-store materialize-to-sign key"
987 }
988 }
989 }"#;
990 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
991 let (backend, rec) = MockBackend::new(Probe::Present);
992 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
993 backends.insert("b".into(), Box::new(backend));
994 let mgr = BackendManager::new(cat, backends).expect("constructs");
995
996 let summary = mgr
997 .reconcile()
998 .await
999 .expect("present kv2 signer reconciles");
1000 assert_eq!(summary.present, 1);
1001 assert_eq!(summary.generated, 0);
1002 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1004 }
1005
1006 #[tokio::test]
1007 async fn materialize_key_without_public_path_probes_absent() {
1008 const NO_PUB: &str = r#"{
1013 "schemaVersion": 1,
1014 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1015 "keys": {
1016 "kv2.signer": {
1017 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
1018 "path": "secret/data/kv2/signer", "writable": true, "missing": "error",
1019 "description": "a kv2 signer missing its publicPath"
1020 }
1021 }
1022 }"#;
1023 let cat: Catalog = serde_json::from_str(NO_PUB).expect("parses");
1024 let (backend, _rec) = MockBackend::new(Probe::Present);
1027 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1028 backends.insert("b".into(), Box::new(backend));
1029 let mgr = BackendManager::new(cat, backends).expect("constructs");
1030
1031 let err = mgr
1032 .reconcile()
1033 .await
1034 .expect_err("a materialize key with no publicPath must fail closed");
1035 match err {
1036 ReconcileError::RequiredMissing(keys) => assert_eq!(keys, vec!["kv2.signer"]),
1037 other => panic!("expected RequiredMissing, got {other:?}"),
1038 }
1039 }
1040
1041 #[tokio::test]
1042 async fn unreachable_backend_is_fatal_not_treated_as_absent() {
1043 let (mgr, rec) = manager_with(Probe::Unreachable);
1046 let err = mgr
1047 .reconcile()
1048 .await
1049 .expect_err("an unreachable backend must fail closed");
1050 match err {
1051 ReconcileError::Probe { source, .. } => {
1052 assert!(matches!(source, BackendError::Transport(_)));
1053 }
1054 other => panic!("expected Probe (unreachable), got {other:?}"),
1055 }
1056 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1059 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1060 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1061 }
1062
1063 #[tokio::test]
1064 async fn present_keys_are_never_created() {
1065 let (mgr, rec) = manager_with(Probe::Present);
1067 mgr.reconcile().await.expect("reconciles");
1068 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1069 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1070 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1071 }
1072
1073 fn status_of<'a>(report: &'a CheckReport, name: &str) -> &'a KeyStatus {
1076 &report
1077 .keys
1078 .iter()
1079 .find(|k| k.name == name)
1080 .expect("key in report")
1081 .status
1082 }
1083
1084 #[tokio::test]
1085 async fn check_all_present_reports_every_key_present_and_creates_nothing() {
1086 let (mgr, rec) = manager_with(Probe::Present);
1087 let report = mgr.check().await.expect("check succeeds when reachable");
1088 assert_eq!(report.keys.len(), 5);
1090 assert_eq!(report.present_count(), 5);
1091 assert_eq!(report.missing().count(), 0);
1092 assert!(!report.should_fail_required());
1093 assert!(report.required_missing().is_empty());
1094 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1096 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1097 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1098 }
1099
1100 #[tokio::test]
1101 async fn check_all_absent_classifies_each_key_by_its_missing_policy() {
1102 let (mgr, rec) = manager_with(Probe::Absent);
1105 let report = mgr.check().await.expect("check succeeds when reachable");
1106
1107 assert_eq!(report.present_count(), 0);
1108 assert_eq!(
1110 *status_of(&report, "req.signer"),
1111 KeyStatus::Missing(MissingPolicy::Error)
1112 );
1113 assert_eq!(
1114 *status_of(&report, "warn.value"),
1115 KeyStatus::Missing(MissingPolicy::Warn)
1116 );
1117 assert_eq!(
1118 *status_of(&report, "gen.signer"),
1119 KeyStatus::Missing(MissingPolicy::Generate)
1120 );
1121 assert_eq!(
1122 *status_of(&report, "gen.box"),
1123 KeyStatus::Missing(MissingPolicy::Generate)
1124 );
1125 assert_eq!(
1126 *status_of(&report, "gen.value"),
1127 KeyStatus::Missing(MissingPolicy::Generate)
1128 );
1129
1130 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1132 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1133 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1134 }
1135
1136 #[tokio::test]
1137 async fn check_should_fail_required_iff_an_error_class_key_is_absent() {
1138 let (mgr, _rec) = manager_with(Probe::Absent);
1141 let report = mgr.check().await.expect("check ok");
1142 assert!(report.should_fail_required());
1143 assert_eq!(report.required_missing(), vec!["req.signer"]);
1144
1145 let (mgr, _rec) = manager_with(Probe::Present);
1147 let report = mgr.check().await.expect("check ok");
1148 assert!(!report.should_fail_required());
1149 assert!(report.required_missing().is_empty());
1150 }
1151
1152 #[tokio::test]
1153 async fn check_warn_and_generate_absent_do_not_trip_require() {
1154 const WARN_GEN_ONLY: &str = r#"{
1157 "schemaVersion": 1,
1158 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1159 "keys": {
1160 "warn.value": {
1161 "class": "value", "backend": "b", "engine": "kv2",
1162 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
1163 "description": "warn"
1164 },
1165 "gen.signer": {
1166 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
1167 "path": "gen-signer", "writable": true, "missing": "generate",
1168 "description": "generate"
1169 }
1170 }
1171 }"#;
1172 let cat: Catalog = serde_json::from_str(WARN_GEN_ONLY).expect("parses");
1173 let (backend, _rec) = MockBackend::new(Probe::Absent);
1174 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1175 backends.insert("b".into(), Box::new(backend));
1176 let mgr = BackendManager::new(cat, backends).expect("constructs");
1177
1178 let report = mgr.check().await.expect("check ok");
1179 assert_eq!(report.missing().count(), 2);
1181 assert!(!report.should_fail_required());
1182 assert!(report.required_missing().is_empty());
1183 }
1184
1185 #[tokio::test]
1186 async fn check_unreachable_backend_is_fatal_not_treated_as_absent() {
1187 let (mgr, _rec) = manager_with(Probe::Unreachable);
1190 let err = mgr
1191 .check()
1192 .await
1193 .expect_err("an unreachable backend must fail closed");
1194 match err {
1195 ReconcileError::Probe { source, .. } => {
1196 assert!(matches!(source, BackendError::Transport(_)));
1197 }
1198 other => panic!("expected Probe (unreachable), got {other:?}"),
1199 }
1200 }
1201
1202 #[tokio::test]
1203 async fn check_report_keys_are_in_catalog_name_order() {
1204 let (mgr, _rec) = manager_with(Probe::Present);
1207 let report = mgr.check().await.expect("check ok");
1208 let names: Vec<&str> = report.keys.iter().map(|k| k.name.as_str()).collect();
1209 let mut sorted = names.clone();
1210 sorted.sort_unstable();
1211 assert_eq!(names, sorted);
1212 }
1213}