1use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8use reqwest::blocking::Client;
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12use crate::errors::{AcpError, AcpResult};
13use crate::http_security::{HttpSecurityPolicy, build_http_client, validate_http_url};
14use crate::identity::{read_identity, sanitize_agent_id};
15
16pub type KeyProviderInfo = Map<String, Value>;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct IdentityKeyMaterial {
20 pub signing_private_key: String,
21 pub encryption_private_key: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub signing_public_key: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub encryption_public_key: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub signing_kid: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub encryption_kid: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct TlsMaterial {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub cert_file: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub key_file: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub ca_file: Option<String>,
40}
41
42pub trait KeyProvider: Send + Sync {
43 fn load_identity_keys(&self, agent_id: &str) -> AcpResult<IdentityKeyMaterial>;
44 fn load_tls_material(&self, agent_id: &str) -> AcpResult<TlsMaterial>;
45 fn load_ca_bundle(&self, agent_id: &str) -> AcpResult<Option<String>>;
46 fn describe(&self) -> KeyProviderInfo;
47}
48
49#[derive(Debug, Clone)]
50pub struct LocalKeyProvider {
51 storage_dir: std::path::PathBuf,
52 cert_file: Option<String>,
53 key_file: Option<String>,
54 ca_file: Option<String>,
55}
56
57impl LocalKeyProvider {
58 pub fn new(
59 storage_dir: std::path::PathBuf,
60 cert_file: Option<String>,
61 key_file: Option<String>,
62 ca_file: Option<String>,
63 ) -> Self {
64 Self {
65 storage_dir,
66 cert_file: normalize_optional(cert_file),
67 key_file: normalize_optional(key_file),
68 ca_file: normalize_optional(ca_file),
69 }
70 }
71}
72
73impl KeyProvider for LocalKeyProvider {
74 fn load_identity_keys(&self, agent_id: &str) -> AcpResult<IdentityKeyMaterial> {
75 let bundle = read_identity(&self.storage_dir, agent_id)?.ok_or_else(|| {
76 AcpError::KeyProvider(format!("Local identity not found for {agent_id}"))
77 })?;
78 Ok(IdentityKeyMaterial {
79 signing_private_key: bundle.identity.signing_private_key,
80 encryption_private_key: bundle.identity.encryption_private_key,
81 signing_public_key: Some(bundle.identity.signing_public_key),
82 encryption_public_key: Some(bundle.identity.encryption_public_key),
83 signing_kid: Some(bundle.identity.signing_kid),
84 encryption_kid: Some(bundle.identity.encryption_kid),
85 })
86 }
87
88 fn load_tls_material(&self, _agent_id: &str) -> AcpResult<TlsMaterial> {
89 Ok(TlsMaterial {
90 cert_file: self.cert_file.clone(),
91 key_file: self.key_file.clone(),
92 ca_file: self.ca_file.clone(),
93 })
94 }
95
96 fn load_ca_bundle(&self, _agent_id: &str) -> AcpResult<Option<String>> {
97 Ok(self.ca_file.clone())
98 }
99
100 fn describe(&self) -> KeyProviderInfo {
101 let mut info = Map::new();
102 info.insert("provider".to_string(), Value::String("local".to_string()));
103 info.insert(
104 "storage_dir".to_string(),
105 Value::String(self.storage_dir.to_string_lossy().to_string()),
106 );
107 info
108 }
109}
110
111#[derive(Debug, Clone)]
112pub struct VaultKeyProvider {
113 vault_url: String,
114 vault_path: String,
115 vault_token_env: String,
116 token: Option<String>,
117 timeout_seconds: u64,
118 http_client: Client,
119 cache: Arc<Mutex<HashMap<String, Map<String, Value>>>>,
120}
121
122impl VaultKeyProvider {
123 #[allow(clippy::too_many_arguments)]
124 pub fn new(
125 vault_url: String,
126 vault_path: String,
127 vault_token_env: Option<String>,
128 token: Option<String>,
129 timeout_seconds: u64,
130 ca_file: Option<String>,
131 allow_insecure_tls: bool,
132 allow_insecure_http: bool,
133 ) -> AcpResult<Self> {
134 let vault_url = trim_and_require(&vault_url, "vault_url")?;
135 validate_http_url(
136 &vault_url,
137 allow_insecure_http,
138 false,
139 "Vault key provider URL",
140 )?;
141 let vault_path = trim_and_require(&vault_path, "vault_path")?
142 .trim_matches('/')
143 .to_string();
144 let vault_token_env = vault_token_env
145 .as_deref()
146 .map(str::trim)
147 .filter(|v| !v.is_empty())
148 .unwrap_or("VAULT_TOKEN")
149 .to_string();
150 let policy = HttpSecurityPolicy {
151 allow_insecure_http,
152 allow_insecure_tls,
153 mtls_enabled: false,
154 ca_file,
155 cert_file: None,
156 key_file: None,
157 };
158 let http_client = build_http_client(timeout_seconds.max(1), &policy)?;
159 Ok(Self {
160 vault_url: vault_url.trim_end_matches('/').to_string(),
161 vault_path,
162 vault_token_env,
163 token: normalize_optional(token),
164 timeout_seconds: timeout_seconds.max(1),
165 http_client,
166 cache: Arc::new(Mutex::new(HashMap::new())),
167 })
168 }
169
170 fn load_secret(&self, agent_id: &str) -> AcpResult<Map<String, Value>> {
171 let path = self.secret_path(agent_id);
172 if let Some(cached) = self.cache.lock().ok().and_then(|c| c.get(&path).cloned()) {
173 return Ok(cached);
174 }
175 let token = self.resolve_token().ok_or_else(|| {
176 AcpError::KeyProvider(format!(
177 "Vault token is missing. Set token or environment variable {}.",
178 self.vault_token_env
179 ))
180 })?;
181 let url = format!("{}/v1/{}", self.vault_url, path.trim_start_matches('/'));
182 let response = self
183 .http_client
184 .get(url)
185 .header("Accept", "application/json")
186 .header("X-Vault-Token", token)
187 .timeout(std::time::Duration::from_secs(self.timeout_seconds))
188 .send()?;
189 if response.status().as_u16() != 200 {
190 return Err(AcpError::KeyProvider(format!(
191 "Vault returned HTTP {} for path {}",
192 response.status().as_u16(),
193 path
194 )));
195 }
196 let payload: Value = response.json()?;
197 let secret = extract_secret_data(payload, &path)?;
198 if let Ok(mut cache) = self.cache.lock() {
199 cache.insert(path, secret.clone());
200 }
201 Ok(secret)
202 }
203
204 fn secret_path(&self, agent_id: &str) -> String {
205 if self.vault_path.contains("{agent_id}") {
206 return self
207 .vault_path
208 .replace("{agent_id}", &sanitize_agent_id(agent_id));
209 }
210 if agent_id.trim().is_empty() {
211 return self.vault_path.clone();
212 }
213 format!("{}/{}", self.vault_path, sanitize_agent_id(agent_id))
214 }
215
216 fn resolve_token(&self) -> Option<String> {
217 if let Some(token) = &self.token {
218 return Some(token.clone());
219 }
220 std::env::var(&self.vault_token_env)
221 .ok()
222 .and_then(|v| normalize_optional(Some(v)))
223 }
224}
225
226impl KeyProvider for VaultKeyProvider {
227 fn load_identity_keys(&self, agent_id: &str) -> AcpResult<IdentityKeyMaterial> {
228 let secret = self.load_secret(agent_id)?;
229 let signing_private_key = secret_value(
230 &secret,
231 &["signing_key", "identity_signing_key", "signing_private_key"],
232 )
233 .ok_or_else(|| {
234 AcpError::KeyProvider(format!(
235 "Vault secret for {agent_id} is missing signing_key"
236 ))
237 })?;
238 let encryption_private_key = secret_value(
239 &secret,
240 &[
241 "encryption_key",
242 "identity_encryption_key",
243 "encryption_private_key",
244 ],
245 )
246 .ok_or_else(|| {
247 AcpError::KeyProvider(format!(
248 "Vault secret for {agent_id} is missing encryption_key"
249 ))
250 })?;
251 Ok(IdentityKeyMaterial {
252 signing_private_key,
253 encryption_private_key,
254 signing_public_key: secret_value(&secret, &["signing_public_key"]),
255 encryption_public_key: secret_value(&secret, &["encryption_public_key"]),
256 signing_kid: secret_value(&secret, &["signing_kid"]),
257 encryption_kid: secret_value(&secret, &["encryption_kid"]),
258 })
259 }
260
261 fn load_tls_material(&self, agent_id: &str) -> AcpResult<TlsMaterial> {
262 let secret = self.load_secret(agent_id)?;
263 Ok(TlsMaterial {
264 cert_file: secret_value(&secret, &["tls_cert_file", "tls_cert", "cert_file"]),
265 key_file: secret_value(&secret, &["tls_key_file", "tls_key", "key_file"]),
266 ca_file: secret_value(&secret, &["ca_bundle_file", "ca_file", "ca_bundle"]),
267 })
268 }
269
270 fn load_ca_bundle(&self, agent_id: &str) -> AcpResult<Option<String>> {
271 let secret = self.load_secret(agent_id)?;
272 Ok(secret_value(
273 &secret,
274 &["ca_bundle_file", "ca_file", "ca_bundle"],
275 ))
276 }
277
278 fn describe(&self) -> KeyProviderInfo {
279 let mut info = Map::new();
280 info.insert("provider".to_string(), Value::String("vault".to_string()));
281 info.insert(
282 "vault_url".to_string(),
283 Value::String(self.vault_url.clone()),
284 );
285 info.insert(
286 "vault_path".to_string(),
287 Value::String(self.vault_path.clone()),
288 );
289 info.insert(
290 "vault_token_env".to_string(),
291 Value::String(self.vault_token_env.clone()),
292 );
293 info
294 }
295}
296
297fn extract_secret_data(payload: Value, path: &str) -> AcpResult<Map<String, Value>> {
298 let data = payload
299 .get("data")
300 .and_then(Value::as_object)
301 .cloned()
302 .ok_or_else(|| {
303 AcpError::KeyProvider(format!(
304 "Vault response for path {path} is missing data object"
305 ))
306 })?;
307 if let Some(nested) = data.get("data").and_then(Value::as_object) {
308 return Ok(nested.clone());
309 }
310 Ok(data)
311}
312
313fn signable(value: &Value) -> Option<String> {
314 value
315 .as_str()
316 .map(str::trim)
317 .filter(|v| !v.is_empty())
318 .map(str::to_string)
319}
320
321fn secret_value(secret: &Map<String, Value>, keys: &[&str]) -> Option<String> {
322 for key in keys {
323 if let Some(value) = secret.get(*key).and_then(signable) {
324 return Some(value);
325 }
326 }
327 None
328}
329
330fn trim_and_require(value: &str, label: &str) -> AcpResult<String> {
331 let normalized = value.trim();
332 if normalized.is_empty() {
333 return Err(AcpError::KeyProvider(format!(
334 "{label} is required for VaultKeyProvider"
335 )));
336 }
337 Ok(normalized.to_string())
338}
339
340fn normalize_optional(value: Option<String>) -> Option<String> {
341 value.and_then(|v| {
342 let normalized = v.trim().to_string();
343 if normalized.is_empty() {
344 None
345 } else {
346 Some(normalized)
347 }
348 })
349}