Skip to main content

khive_runtime/
credentials.rs

1//! Named credential custody (ADR-192). Resolution never exposes material to packs.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::sync::Arc;
6
7use thiserror::Error;
8use zeroize::Zeroizing;
9
10mod config;
11pub(crate) use config::read_tables;
12pub use config::{
13    CredentialConfig, CredentialKind, VisibilityReceiptConfig, VisibilityReceiptKeyConfig,
14};
15
16pub(crate) mod receipt_sealer;
17
18#[cfg(test)]
19mod config_tests;
20#[cfg(test)]
21mod tests;
22
23/// Owned secret bytes, zeroized on drop. Only runtime custody consumers can inspect them.
24///
25/// ```compile_fail
26/// let material = khive_runtime::credentials::CredentialMaterial::new(vec![1, 2, 3]);
27/// let _bytes: &[u8] = &material.bytes;
28/// ```
29///
30/// ```compile_fail
31/// let material = khive_runtime::credentials::CredentialMaterial::new(vec![1, 2, 3]);
32/// let _serialized = serde_json::to_string(&material);
33/// ```
34pub struct CredentialMaterial {
35    bytes: Zeroizing<Vec<u8>>,
36}
37
38impl CredentialMaterial {
39    pub fn new(bytes: Vec<u8>) -> Self {
40        Self {
41            bytes: Zeroizing::new(bytes),
42        }
43    }
44}
45
46impl fmt::Debug for CredentialMaterial {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter.write_str("CredentialMaterial([REDACTED])")
49    }
50}
51
52/// Errors contain credential references and fixed reasons, never provider material or causes.
53#[derive(Debug, Error)]
54pub enum CredentialError {
55    #[error("credential {name:?}: unavailable")]
56    Unavailable { name: String },
57    #[error("credential {name:?}: not configured")]
58    UnknownCredential { name: String },
59    #[error("credential {name:?}: provider {provider:?} is not registered")]
60    UnknownProvider { name: String, provider: String },
61    #[error("credential configuration {name:?}: {reason}")]
62    InvalidConfig { name: String, reason: &'static str },
63    #[error("credential {name:?}: only cookie_jar credentials may be updated")]
64    UpdateNotAllowed { name: String },
65    #[error("credential {name:?}: provider does not support updates")]
66    UpdateUnsupported { name: String },
67}
68
69/// Maximum lifetime for a provider's cached resolution.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum CredentialCacheLifetime {
72    NoCache,
73    Process,
74}
75
76/// Downstream hosts register custody adapters here; env is the only shipped adapter.
77pub trait CredentialProvider: Send + Sync {
78    fn resolve(&self, name: &str) -> Result<CredentialMaterial, CredentialError>;
79
80    fn cache_lifetime(&self) -> CredentialCacheLifetime;
81
82    /// The caller supplies replacement material; this does not unwrap resolved material.
83    fn update(&self, name: &str, _material: Zeroizing<Vec<u8>>) -> Result<(), CredentialError> {
84        Err(CredentialError::UpdateUnsupported {
85            name: name.to_owned(),
86        })
87    }
88}
89
90struct EnvCredentialProvider {
91    variables: BTreeMap<String, String>,
92}
93
94impl CredentialProvider for EnvCredentialProvider {
95    fn resolve(&self, name: &str) -> Result<CredentialMaterial, CredentialError> {
96        let unavailable = || CredentialError::Unavailable {
97            name: name.to_owned(),
98        };
99        let variable = self.variables.get(name).ok_or_else(unavailable)?;
100        let value = std::env::var_os(variable).ok_or_else(unavailable)?;
101        let bytes = Zeroizing::new(value.into_encoded_bytes());
102        if bytes.is_empty() || std::str::from_utf8(&bytes).is_err() {
103            return Err(unavailable());
104        }
105        Ok(CredentialMaterial { bytes })
106    }
107
108    fn cache_lifetime(&self) -> CredentialCacheLifetime {
109        CredentialCacheLifetime::NoCache
110    }
111}
112
113/// Validated references plus provider implementations, without serialized secret values.
114pub struct CredentialRegistry {
115    declarations: BTreeMap<String, CredentialConfig>,
116    providers: BTreeMap<String, Arc<dyn CredentialProvider>>,
117}
118
119impl CredentialRegistry {
120    pub fn new(declarations: Vec<CredentialConfig>) -> Result<Self, CredentialError> {
121        CredentialConfig::validate_all(&declarations)?;
122        let variables = declarations
123            .iter()
124            .filter(|entry| entry.provider == "env")
125            .map(|entry| {
126                (
127                    entry.name.clone(),
128                    entry.env_var.clone().expect("validated env reference"),
129                )
130            })
131            .collect();
132        let mut providers: BTreeMap<String, Arc<dyn CredentialProvider>> = BTreeMap::new();
133        providers.insert(
134            "env".to_owned(),
135            Arc::new(EnvCredentialProvider { variables }),
136        );
137        Ok(Self {
138            declarations: declarations
139                .into_iter()
140                .map(|entry| (entry.name.clone(), entry))
141                .collect(),
142            providers,
143        })
144    }
145
146    /// Registration cannot replace an existing provider, including the built-in env adapter.
147    pub fn register_provider(
148        &mut self,
149        name: String,
150        provider: Arc<dyn CredentialProvider>,
151    ) -> Result<(), CredentialError> {
152        if name.is_empty() || self.providers.contains_key(&name) {
153            return Err(CredentialError::InvalidConfig {
154                name,
155                reason: "provider name must be nonempty and not already registered",
156            });
157        }
158        self.providers.insert(name, provider);
159        Ok(())
160    }
161
162    pub(crate) fn declarations(&self) -> Vec<CredentialConfig> {
163        self.declarations.values().cloned().collect()
164    }
165
166    pub fn kind(&self, name: &str) -> Result<CredentialKind, CredentialError> {
167        Ok(self.declaration(name)?.kind)
168    }
169
170    pub fn cache_lifetime(&self, name: &str) -> Result<CredentialCacheLifetime, CredentialError> {
171        Ok(self.provider(self.declaration(name)?)?.cache_lifetime())
172    }
173
174    pub fn resolve(&self, name: &str) -> Result<CredentialMaterial, CredentialError> {
175        self.provider(self.declaration(name)?)?
176            .resolve(name)
177            .map_err(|_| CredentialError::Unavailable {
178                name: name.to_owned(),
179            })
180    }
181
182    pub fn update(&self, name: &str, material: Vec<u8>) -> Result<(), CredentialError> {
183        let material = Zeroizing::new(material);
184        let declaration = self.declaration(name)?;
185        if declaration.kind != CredentialKind::CookieJar {
186            return Err(CredentialError::UpdateNotAllowed {
187                name: name.to_owned(),
188            });
189        }
190        self.provider(declaration)?
191            .update(name, material)
192            .map_err(|error| match error {
193                CredentialError::UpdateUnsupported { .. } => CredentialError::UpdateUnsupported {
194                    name: name.to_owned(),
195                },
196                _ => CredentialError::Unavailable {
197                    name: name.to_owned(),
198                },
199            })
200    }
201
202    fn declaration(&self, name: &str) -> Result<&CredentialConfig, CredentialError> {
203        self.declarations
204            .get(name)
205            .ok_or_else(|| CredentialError::UnknownCredential {
206                name: name.to_owned(),
207            })
208    }
209
210    fn provider(
211        &self,
212        declaration: &CredentialConfig,
213    ) -> Result<&Arc<dyn CredentialProvider>, CredentialError> {
214        self.providers
215            .get(&declaration.provider)
216            .ok_or_else(|| CredentialError::UnknownProvider {
217                name: declaration.name.clone(),
218                provider: declaration.provider.clone(),
219            })
220    }
221}