Skip to main content

acp_runtime/
key_provider.rs

1// Copyright 2026 ACP Project
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5use 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}