Skip to main content

hara_native/kernel/
secret.rs

1//! Opaque secret requirement resolution for restored sessions.
2//!
3//! The kernel sees descriptors only. Secret bytes remain owned by the host's
4//! provider and are consumed through provider operations, never Hara values.
5
6use crate::snapshot::SecretRequirement;
7use std::collections::BTreeMap;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct SecretDescriptor {
11    pub id: String,
12    pub provider: String,
13    pub version: Option<String>,
14}
15
16pub trait SecretCatalog {
17    fn describe(&self, id: &str) -> Result<Option<SecretDescriptor>, String>;
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ResolvedSecret {
22    pub id: String,
23    pub provider: String,
24    pub version: Option<String>,
25    pub purpose: String,
26}
27
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct ResolvedSecrets {
30    entries: BTreeMap<String, ResolvedSecret>,
31}
32
33impl ResolvedSecrets {
34    pub fn resolve(
35        requirements: &[SecretRequirement],
36        catalog: &dyn SecretCatalog,
37    ) -> Result<Self, String> {
38        let mut entries = BTreeMap::new();
39        for requirement in requirements {
40            let Some(descriptor) = catalog.describe(&requirement.id)? else {
41                if requirement.required {
42                    return Err(format!("secret/required-unavailable: {}", requirement.id));
43                }
44                continue;
45            };
46            if let Some(expected) = requirement.version.as_deref() {
47                if descriptor.version.as_deref() != Some(expected) {
48                    return Err(format!(
49                        "secret/provider-version-mismatch: {} expected {expected}, received {}",
50                        requirement.id,
51                        descriptor.version.as_deref().unwrap_or("unspecified")
52                    ));
53                }
54            }
55            entries.insert(
56                requirement.id.clone(),
57                ResolvedSecret {
58                    id: requirement.id.clone(),
59                    provider: descriptor.provider,
60                    version: descriptor.version,
61                    purpose: requirement.purpose.clone(),
62                },
63            );
64        }
65        Ok(Self { entries })
66    }
67
68    pub fn get(&self, id: &str) -> Option<&ResolvedSecret> {
69        self.entries.get(id)
70    }
71
72    pub fn ids(&self) -> impl Iterator<Item = &str> {
73        self.entries.keys().map(String::as_str)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    struct Catalog(BTreeMap<String, SecretDescriptor>);
82
83    impl SecretCatalog for Catalog {
84        fn describe(&self, id: &str) -> Result<Option<SecretDescriptor>, String> {
85            Ok(self.0.get(id).cloned())
86        }
87    }
88
89    fn requirement(required: bool, version: Option<&str>) -> SecretRequirement {
90        SecretRequirement {
91            id: "payments".into(),
92            purpose: "sign payment requests".into(),
93            required,
94            version: version.map(str::to_owned),
95        }
96    }
97
98    #[test]
99    fn resolves_descriptors_without_receiving_secret_material() {
100        let catalog = Catalog(BTreeMap::from([(
101            "payments".into(),
102            SecretDescriptor {
103                id: "payments".into(),
104                provider: "kms".into(),
105                version: Some("7".into()),
106            },
107        )]));
108        let resolved = ResolvedSecrets::resolve(&[requirement(true, Some("7"))], &catalog).unwrap();
109        assert_eq!(resolved.get("payments").unwrap().provider, "kms");
110        assert_eq!(resolved.ids().collect::<Vec<_>>(), ["payments"]);
111    }
112
113    #[test]
114    fn required_missing_or_wrong_version_prevents_publication() {
115        let empty = Catalog(BTreeMap::new());
116        assert!(ResolvedSecrets::resolve(&[requirement(true, None)], &empty)
117            .unwrap_err()
118            .contains("required-unavailable"));
119        assert!(
120            ResolvedSecrets::resolve(&[requirement(false, None)], &empty)
121                .unwrap()
122                .ids()
123                .next()
124                .is_none()
125        );
126
127        let catalog = Catalog(BTreeMap::from([(
128            "payments".into(),
129            SecretDescriptor {
130                id: "payments".into(),
131                provider: "kms".into(),
132                version: Some("8".into()),
133            },
134        )]));
135        assert!(
136            ResolvedSecrets::resolve(&[requirement(true, Some("7"))], &catalog)
137                .unwrap_err()
138                .contains("version-mismatch")
139        );
140    }
141}