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<zeroize::Zeroizing<Vec<u8>>, BackendError> {
579 match self.probe {
580 Probe::Present => Ok(zeroize::Zeroizing::new(b"present".to_vec())),
581 Probe::Absent => Err(BackendError::KeyNotFound("absent".into())),
582 Probe::Unreachable => Err(BackendError::Transport("connection refused".into())),
583 }
584 }
585
586 async fn create_named_key(
587 &self,
588 key_id: &str,
589 key_type: KeyType,
590 ) -> Result<NewKey, BackendError> {
591 self.rec
592 .create_named_key_calls
593 .fetch_add(1, Ordering::SeqCst);
594 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
595 *self.rec.last_key_type.lock().unwrap() = Some(key_type);
596 Ok(NewKey {
597 key_id: key_id.to_string(),
598 public_key: vec![1, 2, 3],
599 })
600 }
601
602 async fn create_named_aead(
603 &self,
604 key_id: &str,
605 aead: AeadAlgorithm,
606 ) -> Result<(), BackendError> {
607 self.rec
608 .create_named_aead_calls
609 .fetch_add(1, Ordering::SeqCst);
610 *self.rec.last_create_path.lock().unwrap() = Some(key_id.to_string());
611 *self.rec.last_aead.lock().unwrap() = Some(aead);
612 Ok(())
613 }
614
615 async fn kv_put(&self, key_id: &str, value: &[u8]) -> Result<u32, BackendError> {
616 self.rec.kv_put_calls.fetch_add(1, Ordering::SeqCst);
617 *self.rec.last_kv_put.lock().unwrap() = Some((key_id.to_string(), value.to_vec()));
618 Ok(1)
619 }
620
621 async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
622 Err(BackendError::Unsupported("sign"))
623 }
624
625 async fn verify(
626 &self,
627 _key_id: &str,
628 _message: &[u8],
629 _signature: &[u8],
630 ) -> Result<bool, BackendError> {
631 Err(BackendError::Unsupported("verify"))
632 }
633 }
634
635 const CATALOG: &str = r#"{
638 "schemaVersion": 1,
639 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
640 "keys": {
641 "req.signer": {
642 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
643 "path": "req-signer", "writable": true, "missing": "error",
644 "description": "a required signing key"
645 },
646 "warn.value": {
647 "class": "value", "backend": "b", "engine": "kv2",
648 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
649 "description": "a warn-on-missing value"
650 },
651 "gen.signer": {
652 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
653 "path": "gen-signer", "writable": true, "missing": "generate",
654 "description": "a generate-on-missing signing key"
655 },
656 "gen.box": {
657 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
658 "path": "gen-box", "writable": true, "missing": "generate",
659 "description": "a generate-on-missing AEAD key"
660 },
661 "gen.value": {
662 "class": "value", "backend": "b", "engine": "kv2",
663 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
664 "generate": { "format": "ascii-printable", "bytes": 24 },
665 "description": "a generate-on-missing value"
666 }
667 }
668 }"#;
669
670 fn parse() -> Catalog {
671 serde_json::from_str(CATALOG).expect("catalog parses")
672 }
673
674 fn manager_with(probe: Probe) -> (BackendManager, std::sync::Arc<Recorder>) {
677 let (backend, rec) = MockBackend::new(probe);
678 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
679 backends.insert("b".into(), Box::new(backend));
680 let mgr = BackendManager::new(parse(), backends).expect("manager constructs");
681 (mgr, rec)
682 }
683
684 #[tokio::test]
685 async fn all_present_is_a_noop_with_present_count() {
686 let (mgr, rec) = manager_with(Probe::Present);
687 let summary = mgr.reconcile().await.expect("all-present reconciles ok");
688 assert_eq!(summary.present, 5);
689 assert_eq!(summary.generated, 0);
690 assert_eq!(summary.warned, 0);
691 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
693 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
694 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
695 }
696
697 #[tokio::test]
698 async fn required_missing_is_fatal_and_reports_the_key() {
699 let (mgr, _rec) = manager_with(Probe::Absent);
702 let err = mgr
703 .reconcile()
704 .await
705 .expect_err("a required-missing key must fail closed");
706 match err {
707 ReconcileError::RequiredMissing(keys) => {
708 assert_eq!(keys, vec!["req.signer".to_string()]);
709 }
710 other => panic!("expected RequiredMissing, got {other:?}"),
711 }
712 }
713
714 #[tokio::test]
715 async fn warn_missing_is_ok_and_counted() {
716 const NO_REQUIRED: &str = r#"{
722 "schemaVersion": 1,
723 "backends": {
724 "b": {
725 "kind": "vault", "addr": "http://127.0.0.1:8200",
726 "engines": ["transit", "kv2"], "capabilities": [],
727 "mintKeyTypes": ["ed25519"]
728 }
729 },
730 "keys": {
731 "warn.value": {
732 "class": "value", "backend": "b", "engine": "kv2",
733 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
734 "description": "a warn-on-missing value"
735 }
736 }
737 }"#;
738 let cat: Catalog = serde_json::from_str(NO_REQUIRED).expect("parses");
739 let (backend, _rec) = MockBackend::new(Probe::Absent);
740 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
741 backends.insert("b".into(), Box::new(backend));
742 let mgr = BackendManager::new(cat, backends).expect("constructs");
743 let summary = mgr.reconcile().await.expect("warn-only reconciles ok");
744 assert_eq!(summary.warned, 1);
745 assert_eq!(summary.present, 0);
746 assert_eq!(summary.generated, 0);
747 }
748
749 #[tokio::test]
750 async fn generate_missing_creates_crypto_and_value_keys() {
751 const GEN_ONLY: &str = r#"{
754 "schemaVersion": 1,
755 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
756 "keys": {
757 "gen.signer": {
758 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
759 "path": "gen-signer", "writable": true, "missing": "generate",
760 "description": "asym generate"
761 },
762 "gen.box": {
763 "class": "symmetric", "keyType": "aes-256-gcm", "backend": "b",
764 "path": "gen-box", "writable": true, "missing": "generate",
765 "description": "sym generate"
766 },
767 "gen.value": {
768 "class": "value", "backend": "b", "engine": "kv2",
769 "path": "secret/data/gen/value", "writable": true, "missing": "generate",
770 "generate": { "format": "ascii-printable", "bytes": 24 },
771 "description": "value generate"
772 }
773 }
774 }"#;
775 let cat: Catalog = serde_json::from_str(GEN_ONLY).expect("parses");
776 let (backend, rec) = MockBackend::new(Probe::Absent);
777 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
778 backends.insert("b".into(), Box::new(backend));
779 let mgr = BackendManager::new(cat, backends).expect("constructs");
780
781 let summary = mgr.reconcile().await.expect("generate reconciles ok");
782 assert_eq!(summary.generated, 3);
783 assert_eq!(summary.present, 0);
784 assert_eq!(summary.warned, 0);
785
786 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
788 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Ed25519));
789 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 1);
791 assert_eq!(
792 *rec.last_aead.lock().unwrap(),
793 Some(AeadAlgorithm::Aes256Gcm)
794 );
795 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 1);
797 let (path, value) = rec.last_kv_put.lock().unwrap().clone().expect("a kv write");
798 assert_eq!(path, "secret/data/gen/value");
799 assert_eq!(value.len(), 24);
800 assert!(value.iter().all(|b| (b'!'..=b'~').contains(b)));
801 }
802
803 fn generated_asymmetric_catalog(key_type: &str, mint_key_types: &str) -> String {
804 format!(
805 r#"{{
806 "schemaVersion": 1,
807 "backends": {{
808 "b": {{
809 "kind": "vault", "addr": "http://127.0.0.1:8200",
810 "engines": ["transit"], "capabilities": [],
811 "mintKeyTypes": [{mint_key_types}]
812 }}
813 }},
814 "keys": {{
815 "gen.signer": {{
816 "class": "asymmetric", "keyType": "{key_type}", "backend": "b",
817 "path": "gen-signer", "writable": true, "missing": "generate",
818 "description": "generated signer"
819 }}
820 }}
821 }}"#
822 )
823 }
824
825 #[tokio::test]
826 async fn generate_missing_creates_rsa_when_static_preset_allows_it() {
827 let cat: Catalog =
828 serde_json::from_str(&generated_asymmetric_catalog("rsa-2048", r#""rsa-2048""#))
829 .expect("parses");
830 let (backend, rec) = MockBackend::new(Probe::Absent);
831 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
832 backends.insert("b".into(), Box::new(backend));
833 let mgr = BackendManager::new(cat, backends).expect("constructs");
834
835 let summary = mgr.reconcile().await.expect("rsa generate reconciles ok");
836 assert_eq!(summary.generated, 1);
837 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
838 assert_eq!(
839 rec.last_create_path.lock().unwrap().as_deref(),
840 Some("gen-signer")
841 );
842 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::Rsa2048));
843 }
844
845 #[tokio::test]
846 async fn generate_missing_creates_ecdsa_p256_when_static_preset_allows_it() {
847 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
848 "ecdsa-p256",
849 r#""ecdsa-p256""#,
850 ))
851 .expect("parses");
852 let (backend, rec) = MockBackend::new(Probe::Absent);
853 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
854 backends.insert("b".into(), Box::new(backend));
855 let mgr = BackendManager::new(cat, backends).expect("constructs");
856
857 let summary = mgr.reconcile().await.expect("ecdsa generate reconciles ok");
858 assert_eq!(summary.generated, 1);
859 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
860 assert_eq!(
861 rec.last_create_path.lock().unwrap().as_deref(),
862 Some("gen-signer")
863 );
864 assert_eq!(*rec.last_key_type.lock().unwrap(), Some(KeyType::EcdsaP256));
865 }
866
867 #[tokio::test]
868 async fn generate_missing_creates_nkey_when_static_preset_allows_it() {
869 let cat: Catalog = serde_json::from_str(&generated_asymmetric_catalog(
870 "ed25519-nkey",
871 r#""ed25519-nkey""#,
872 ))
873 .expect("parses");
874 let (backend, rec) = MockBackend::new(Probe::Absent);
875 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
876 backends.insert("b".into(), Box::new(backend));
877 let mgr = BackendManager::new(cat, backends).expect("constructs");
878
879 let summary = mgr.reconcile().await.expect("nkey generate reconciles ok");
880 assert_eq!(summary.generated, 1);
881 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 1);
882 assert_eq!(
883 rec.last_create_path.lock().unwrap().as_deref(),
884 Some("gen-signer")
885 );
886 assert_eq!(
887 *rec.last_key_type.lock().unwrap(),
888 Some(KeyType::Ed25519Nkey)
889 );
890 }
891
892 #[tokio::test]
893 async fn generate_missing_rejects_key_type_absent_from_static_backend_preset() {
894 const GEN_RSA_UNSUPPORTED: &str = r#"{
895 "schemaVersion": 1,
896 "backends": {
897 "b": {
898 "kind": "vault", "addr": "http://127.0.0.1:8200",
899 "engines": ["transit"], "capabilities": [],
900 "mintKeyTypes": ["ed25519"]
901 }
902 },
903 "keys": {
904 "gen.rsa": {
905 "class": "asymmetric", "keyType": "rsa-2048", "backend": "b",
906 "path": "gen-rsa", "writable": true, "missing": "generate",
907 "description": "rsa generate"
908 }
909 }
910 }"#;
911 let cat: Catalog = serde_json::from_str(GEN_RSA_UNSUPPORTED).expect("parses");
912 let (backend, rec) = MockBackend::new(Probe::Absent);
913 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
914 backends.insert("b".into(), Box::new(backend));
915 let mgr = BackendManager::new(cat, backends).expect("constructs");
916
917 let err = mgr
918 .reconcile()
919 .await
920 .expect_err("rsa is absent from preset");
921 assert!(matches!(
922 err,
923 ReconcileError::Generate {
924 source: ManagerError::UnsupportedKeyType {
925 backend,
926 op: "generate",
927 key_type: KeyType::Rsa2048
928 },
929 ..
930 } if backend == "b"
931 ));
932 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
933 }
934
935 #[tokio::test]
936 async fn kv2_signer_missing_generate_refuses_in_broker() {
937 const KV2_SIGNER: &str = r#"{
943 "schemaVersion": 1,
944 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
945 "keys": {
946 "kv2.signer": {
947 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
948 "path": "secret/data/kv2/signer", "writable": true, "missing": "generate",
949 "description": "a value-store materialize-to-sign key"
950 }
951 }
952 }"#;
953 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
954 let (backend, rec) = MockBackend::new(Probe::Absent);
955 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
956 backends.insert("b".into(), Box::new(backend));
957 let mgr = BackendManager::new(cat, backends).expect("constructs");
958
959 let err = mgr
960 .reconcile()
961 .await
962 .expect_err("a kv2 signing key cannot be generated in-broker");
963 assert!(matches!(err, ReconcileError::Generate { .. }));
964 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
966 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
967 }
968
969 #[tokio::test]
970 async fn kv2_signer_present_probes_kv_not_transit() {
971 const KV2_SIGNER: &str = r#"{
975 "schemaVersion": 1,
976 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
977 "keys": {
978 "kv2.signer": {
979 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
980 "path": "secret/data/kv2/signer",
981 "publicPath": "secret/data/kv2/signer-public",
982 "writable": true, "missing": "error",
983 "description": "a value-store materialize-to-sign key"
984 }
985 }
986 }"#;
987 let cat: Catalog = serde_json::from_str(KV2_SIGNER).expect("parses");
988 let (backend, rec) = MockBackend::new(Probe::Present);
989 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
990 backends.insert("b".into(), Box::new(backend));
991 let mgr = BackendManager::new(cat, backends).expect("constructs");
992
993 let summary = mgr
994 .reconcile()
995 .await
996 .expect("present kv2 signer reconciles");
997 assert_eq!(summary.present, 1);
998 assert_eq!(summary.generated, 0);
999 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1001 }
1002
1003 #[tokio::test]
1004 async fn materialize_key_without_public_path_probes_absent() {
1005 const NO_PUB: &str = r#"{
1010 "schemaVersion": 1,
1011 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1012 "keys": {
1013 "kv2.signer": {
1014 "class": "asymmetric", "keyType": "ed25519", "backend": "b", "engine": "kv2",
1015 "path": "secret/data/kv2/signer", "writable": true, "missing": "error",
1016 "description": "a kv2 signer missing its publicPath"
1017 }
1018 }
1019 }"#;
1020 let cat: Catalog = serde_json::from_str(NO_PUB).expect("parses");
1021 let (backend, _rec) = MockBackend::new(Probe::Present);
1024 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1025 backends.insert("b".into(), Box::new(backend));
1026 let mgr = BackendManager::new(cat, backends).expect("constructs");
1027
1028 let err = mgr
1029 .reconcile()
1030 .await
1031 .expect_err("a materialize key with no publicPath must fail closed");
1032 match err {
1033 ReconcileError::RequiredMissing(keys) => assert_eq!(keys, vec!["kv2.signer"]),
1034 other => panic!("expected RequiredMissing, got {other:?}"),
1035 }
1036 }
1037
1038 #[tokio::test]
1039 async fn unreachable_backend_is_fatal_not_treated_as_absent() {
1040 let (mgr, rec) = manager_with(Probe::Unreachable);
1043 let err = mgr
1044 .reconcile()
1045 .await
1046 .expect_err("an unreachable backend must fail closed");
1047 match err {
1048 ReconcileError::Probe { source, .. } => {
1049 assert!(matches!(source, BackendError::Transport(_)));
1050 }
1051 other => panic!("expected Probe (unreachable), got {other:?}"),
1052 }
1053 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1056 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1057 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1058 }
1059
1060 #[tokio::test]
1061 async fn present_keys_are_never_created() {
1062 let (mgr, rec) = manager_with(Probe::Present);
1064 mgr.reconcile().await.expect("reconciles");
1065 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1066 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1067 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1068 }
1069
1070 fn status_of<'a>(report: &'a CheckReport, name: &str) -> &'a KeyStatus {
1073 &report
1074 .keys
1075 .iter()
1076 .find(|k| k.name == name)
1077 .expect("key in report")
1078 .status
1079 }
1080
1081 #[tokio::test]
1082 async fn check_all_present_reports_every_key_present_and_creates_nothing() {
1083 let (mgr, rec) = manager_with(Probe::Present);
1084 let report = mgr.check().await.expect("check succeeds when reachable");
1085 assert_eq!(report.keys.len(), 5);
1087 assert_eq!(report.present_count(), 5);
1088 assert_eq!(report.missing().count(), 0);
1089 assert!(!report.should_fail_required());
1090 assert!(report.required_missing().is_empty());
1091 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1093 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1094 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1095 }
1096
1097 #[tokio::test]
1098 async fn check_all_absent_classifies_each_key_by_its_missing_policy() {
1099 let (mgr, rec) = manager_with(Probe::Absent);
1102 let report = mgr.check().await.expect("check succeeds when reachable");
1103
1104 assert_eq!(report.present_count(), 0);
1105 assert_eq!(
1107 *status_of(&report, "req.signer"),
1108 KeyStatus::Missing(MissingPolicy::Error)
1109 );
1110 assert_eq!(
1111 *status_of(&report, "warn.value"),
1112 KeyStatus::Missing(MissingPolicy::Warn)
1113 );
1114 assert_eq!(
1115 *status_of(&report, "gen.signer"),
1116 KeyStatus::Missing(MissingPolicy::Generate)
1117 );
1118 assert_eq!(
1119 *status_of(&report, "gen.box"),
1120 KeyStatus::Missing(MissingPolicy::Generate)
1121 );
1122 assert_eq!(
1123 *status_of(&report, "gen.value"),
1124 KeyStatus::Missing(MissingPolicy::Generate)
1125 );
1126
1127 assert_eq!(rec.create_named_key_calls.load(Ordering::SeqCst), 0);
1129 assert_eq!(rec.create_named_aead_calls.load(Ordering::SeqCst), 0);
1130 assert_eq!(rec.kv_put_calls.load(Ordering::SeqCst), 0);
1131 }
1132
1133 #[tokio::test]
1134 async fn check_should_fail_required_iff_an_error_class_key_is_absent() {
1135 let (mgr, _rec) = manager_with(Probe::Absent);
1138 let report = mgr.check().await.expect("check ok");
1139 assert!(report.should_fail_required());
1140 assert_eq!(report.required_missing(), vec!["req.signer"]);
1141
1142 let (mgr, _rec) = manager_with(Probe::Present);
1144 let report = mgr.check().await.expect("check ok");
1145 assert!(!report.should_fail_required());
1146 assert!(report.required_missing().is_empty());
1147 }
1148
1149 #[tokio::test]
1150 async fn check_warn_and_generate_absent_do_not_trip_require() {
1151 const WARN_GEN_ONLY: &str = r#"{
1154 "schemaVersion": 1,
1155 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1156 "keys": {
1157 "warn.value": {
1158 "class": "value", "backend": "b", "engine": "kv2",
1159 "path": "secret/data/warn/value", "writable": true, "missing": "warn",
1160 "description": "warn"
1161 },
1162 "gen.signer": {
1163 "class": "asymmetric", "keyType": "ed25519", "backend": "b",
1164 "path": "gen-signer", "writable": true, "missing": "generate",
1165 "description": "generate"
1166 }
1167 }
1168 }"#;
1169 let cat: Catalog = serde_json::from_str(WARN_GEN_ONLY).expect("parses");
1170 let (backend, _rec) = MockBackend::new(Probe::Absent);
1171 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1172 backends.insert("b".into(), Box::new(backend));
1173 let mgr = BackendManager::new(cat, backends).expect("constructs");
1174
1175 let report = mgr.check().await.expect("check ok");
1176 assert_eq!(report.missing().count(), 2);
1178 assert!(!report.should_fail_required());
1179 assert!(report.required_missing().is_empty());
1180 }
1181
1182 #[tokio::test]
1183 async fn check_unreachable_backend_is_fatal_not_treated_as_absent() {
1184 let (mgr, _rec) = manager_with(Probe::Unreachable);
1187 let err = mgr
1188 .check()
1189 .await
1190 .expect_err("an unreachable backend must fail closed");
1191 match err {
1192 ReconcileError::Probe { source, .. } => {
1193 assert!(matches!(source, BackendError::Transport(_)));
1194 }
1195 other => panic!("expected Probe (unreachable), got {other:?}"),
1196 }
1197 }
1198
1199 #[tokio::test]
1200 async fn check_report_keys_are_in_catalog_name_order() {
1201 let (mgr, _rec) = manager_with(Probe::Present);
1204 let report = mgr.check().await.expect("check ok");
1205 let names: Vec<&str> = report.keys.iter().map(|k| k.name.as_str()).collect();
1206 let mut sorted = names.clone();
1207 sorted.sort_unstable();
1208 assert_eq!(names, sorted);
1209 }
1210}