1use crate::Result;
11use anyhow::Context;
12use keyring::Entry;
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18enum Secret {
19 Token,
20 DpopKey,
21 RefreshToken,
22 Meta,
23}
24
25impl Secret {
26 fn suffix(self) -> &'static str {
28 match self {
29 Secret::Token => "token",
30 Secret::DpopKey => "dpop",
31 Secret::RefreshToken => "refresh",
32 Secret::Meta => "meta",
33 }
34 }
35
36 fn label(self) -> &'static str {
37 match self {
38 Secret::Token => "token",
39 Secret::DpopKey => "DPoP key",
40 Secret::RefreshToken => "refresh token",
41 Secret::Meta => "credential metadata",
42 }
43 }
44}
45
46#[cfg(target_os = "linux")]
49const KEYCHAIN_HINT: &str = " (on Linux this needs a running Secret Service such as GNOME \
50 Keyring or KWallet; MCP_AIRLOCK_USE_MEMORY_VAULT=1 keeps credentials in memory only)";
51#[cfg(not(target_os = "linux"))]
52const KEYCHAIN_HINT: &str = "";
53
54#[derive(Clone)]
55enum Backend {
56 Keyring,
58 Memory(Arc<Mutex<HashMap<String, String>>>),
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64pub struct CredentialMeta {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub issuer: Option<String>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub scopes: Vec<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub expires_at: Option<u64>,
75}
76
77#[derive(Clone)]
79pub struct Vault {
80 pub service: String,
82 backend: Backend,
83}
84
85pub fn service_name_for(remote_url: &str) -> String {
90 use sha2::{Digest, Sha256};
91 let digest = Sha256::digest(remote_url.trim_end_matches('/').as_bytes());
92 format!("mcp-airlock:{}", &hex::encode(digest)[..16])
93}
94
95impl Vault {
96 pub fn keyring(service: &str) -> Self {
98 Self {
99 service: service.to_string(),
100 backend: Backend::Keyring,
101 }
102 }
103
104 pub fn in_memory(service: &str) -> Self {
106 Self {
107 service: service.to_string(),
108 backend: Backend::Memory(Arc::new(Mutex::new(HashMap::new()))),
109 }
110 }
111
112 pub fn from_env(service: &str) -> Self {
115 if std::env::var("MCP_AIRLOCK_USE_MEMORY_VAULT").is_ok() {
116 Self::in_memory(service)
117 } else {
118 Self::keyring(service)
119 }
120 }
121
122 fn make_key(&self, user_id: &str, suffix: &str) -> String {
123 format!("{}:{}:{}", self.service, user_id, suffix)
124 }
125
126 fn keyring_entry(&self, user_id: &str, secret: Secret) -> Result<Entry> {
127 let service = match secret {
128 Secret::Token => self.service.clone(),
130 _ => format!("{}-{}", self.service, secret.suffix()),
131 };
132 Ok(Entry::new(&service, user_id)?)
133 }
134
135 fn set(&self, user_id: &str, secret: Secret, value: &str) -> Result<()> {
136 match &self.backend {
137 Backend::Memory(store) => {
138 store
139 .lock()
140 .map_err(|e| anyhow::anyhow!("Mutex poisoned: {}", e))?
141 .insert(self.make_key(user_id, secret.suffix()), value.to_string());
142 Ok(())
143 }
144 Backend::Keyring => self
145 .keyring_entry(user_id, secret)?
146 .set_password(value)
147 .with_context(|| {
148 format!(
149 "Failed to store {} in the OS keychain{}",
150 secret.label(),
151 KEYCHAIN_HINT
152 )
153 }),
154 }
155 }
156
157 fn get(&self, user_id: &str, secret: Secret) -> Result<Option<String>> {
158 match &self.backend {
159 Backend::Memory(store) => Ok(store
160 .lock()
161 .map_err(|e| anyhow::anyhow!("Mutex poisoned: {}", e))?
162 .get(&self.make_key(user_id, secret.suffix()))
163 .cloned()),
164 Backend::Keyring => match self.keyring_entry(user_id, secret)?.get_password() {
165 Ok(v) => Ok(Some(v)),
166 Err(keyring::Error::NoEntry) => Ok(None),
167 Err(e) => Err(anyhow::anyhow!(e)
168 .context(format!("Failed to retrieve {} from vault", secret.label()))),
169 },
170 }
171 }
172
173 fn delete(&self, user_id: &str, secret: Secret) -> Result<()> {
174 match &self.backend {
175 Backend::Memory(store) => {
176 store
177 .lock()
178 .map_err(|e| anyhow::anyhow!("Mutex poisoned: {}", e))?
179 .remove(&self.make_key(user_id, secret.suffix()));
180 }
181 Backend::Keyring => {
182 let _ = self.keyring_entry(user_id, secret)?.delete_credential();
183 }
184 }
185 Ok(())
186 }
187
188 pub fn store_token(&self, user_id: &str, token: &str) -> Result<()> {
190 self.set(user_id, Secret::Token, token)
191 }
192
193 pub fn get_token(&self, user_id: &str) -> Result<Option<String>> {
195 self.get(user_id, Secret::Token)
196 }
197
198 pub fn delete_token(&self, user_id: &str) -> Result<()> {
200 self.delete(user_id, Secret::Token)
201 }
202
203 pub fn store_dpop_key(&self, user_id: &str, key_bytes: &[u8]) -> Result<()> {
205 self.set(user_id, Secret::DpopKey, &hex::encode(key_bytes))
206 }
207
208 pub fn get_dpop_key(&self, user_id: &str) -> Result<Option<Vec<u8>>> {
210 self.get(user_id, Secret::DpopKey)?
211 .map(|h| hex::decode(h).context("Failed to decode DPoP key hex"))
212 .transpose()
213 }
214
215 pub fn delete_dpop_key(&self, user_id: &str) -> Result<()> {
217 self.delete(user_id, Secret::DpopKey)
218 }
219
220 pub fn store_refresh_token(&self, user_id: &str, token: &str) -> Result<()> {
222 self.set(user_id, Secret::RefreshToken, token)
223 }
224
225 pub fn get_refresh_token(&self, user_id: &str) -> Result<Option<String>> {
227 self.get(user_id, Secret::RefreshToken)
228 }
229
230 pub fn delete_refresh_token(&self, user_id: &str) -> Result<()> {
232 self.delete(user_id, Secret::RefreshToken)
233 }
234
235 pub fn store_meta(&self, user_id: &str, meta: &CredentialMeta) -> Result<()> {
237 self.set(user_id, Secret::Meta, &serde_json::to_string(meta)?)
238 }
239
240 pub fn get_meta(&self, user_id: &str) -> Result<Option<CredentialMeta>> {
242 Ok(self
243 .get(user_id, Secret::Meta)?
244 .and_then(|m| serde_json::from_str(&m).ok()))
245 }
246
247 pub fn clear(&self, user_id: &str) -> Result<()> {
249 for secret in [
250 Secret::Token,
251 Secret::DpopKey,
252 Secret::RefreshToken,
253 Secret::Meta,
254 ] {
255 self.delete(user_id, secret)?;
256 }
257 Ok(())
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_service_name_for() {
267 let a = service_name_for("https://a.example.com/mcp");
268 assert!(a.starts_with("mcp-airlock:"));
269 assert_eq!(a.len(), "mcp-airlock:".len() + 16);
270 assert_eq!(a, service_name_for("https://a.example.com/mcp/"));
271 assert_ne!(a, service_name_for("https://b.example.com/mcp"));
272 }
273
274 #[test]
275 fn test_make_key() {
276 let vault = Vault::in_memory("my-service");
277
278 assert_eq!(
280 vault.make_key("user123", "token"),
281 "my-service:user123:token"
282 );
283
284 assert_eq!(vault.make_key("", "token"), "my-service::token");
286 assert_eq!(vault.make_key("user123", ""), "my-service:user123:");
287 assert_eq!(vault.make_key("", ""), "my-service::");
288
289 assert_eq!(
291 vault.make_key("user@domain.com", "dpop"),
292 "my-service:user@domain.com:dpop"
293 );
294 }
295
296 #[test]
297 fn test_vault_token_ops() -> Result<()> {
298 let vault = Vault::in_memory("mcp-airlock-test");
299 let user = "test_user_1";
300 let token = "test_token_123";
301
302 vault.store_token(user, token)?;
303 assert_eq!(vault.get_token(user)?, Some(token.to_string()));
304
305 vault.delete_token(user)?;
306 assert_eq!(vault.get_token(user)?, None);
307 Ok(())
308 }
309
310 #[test]
311 fn test_vault_refresh_token_ops() -> Result<()> {
312 let vault = Vault::in_memory("mcp-airlock-test");
313 vault.store_token("u", "access")?;
314 vault.store_refresh_token("u", "refresh")?;
315 assert_eq!(vault.get_refresh_token("u")?, Some("refresh".into()));
316 assert_eq!(vault.get_token("u")?, Some("access".into()));
318 vault.delete_refresh_token("u")?;
319 assert_eq!(vault.get_refresh_token("u")?, None);
320 assert_eq!(vault.get_token("u")?, Some("access".into()));
321 Ok(())
322 }
323
324 #[test]
325 fn test_vault_meta_and_clear() -> Result<()> {
326 let vault = Vault::in_memory("svc");
327 assert_eq!(vault.get_meta("u")?, None);
328 let meta = CredentialMeta {
329 issuer: Some("https://as.example.com".into()),
330 scopes: vec!["read".into()],
331 expires_at: Some(42),
332 };
333 vault.store_meta("u", &meta)?;
334 assert_eq!(vault.get_meta("u")?, Some(meta));
335
336 vault.store_token("u", "t")?;
337 vault.store_refresh_token("u", "r")?;
338 vault.clear("u")?;
339 assert_eq!(vault.get_token("u")?, None);
340 assert_eq!(vault.get_refresh_token("u")?, None);
341 assert_eq!(vault.get_meta("u")?, None);
342
343 vault.set("u", Secret::Meta, "not json")?;
344 assert_eq!(vault.get_meta("u")?, None);
345 Ok(())
346 }
347
348 #[test]
349 fn test_delete_nonexistent_token() -> Result<()> {
350 let vault = Vault::in_memory("mcp-airlock-test-nonexistent");
351 assert!(vault.delete_token("non_existent_user").is_ok());
352 Ok(())
353 }
354
355 #[test]
356 fn test_vault_dpop_ops() -> Result<()> {
357 let vault = Vault::in_memory("mcp-airlock-test");
358 let user = "test_user_dpop";
359 let key_bytes = b"test_key_bytes_123456789012345678";
360
361 vault.store_dpop_key(user, key_bytes)?;
362 assert_eq!(vault.get_dpop_key(user)?, Some(key_bytes.to_vec()));
363
364 assert_eq!(vault.get_dpop_key("non_existent")?, None);
365
366 vault.delete_dpop_key(user)?;
367 assert_eq!(vault.get_dpop_key(user)?, None);
368 Ok(())
369 }
370
371 #[test]
372 fn test_delete_nonexistent_dpop_key() -> Result<()> {
373 let vault = Vault::in_memory("mcp-airlock-test-nonexistent-dpop");
374 assert!(vault.delete_dpop_key("non_existent_user").is_ok());
375 Ok(())
376 }
377
378 #[test]
379 fn test_vault_dpop_hex_failure() -> Result<()> {
380 let vault = Vault::in_memory("mcp-airlock-test");
381 let user = "test_user_bad_hex";
382 vault.set(user, Secret::DpopKey, "invalid hex")?;
383
384 let res = vault.get_dpop_key(user);
385 assert!(res.is_err());
386 assert!(format!("{:?}", res.err().unwrap()).contains("Failed to decode DPoP key hex"));
387 Ok(())
388 }
389
390 #[test]
391 fn test_in_memory_vaults_are_isolated_but_clones_share() -> Result<()> {
392 let a = Vault::in_memory("svc");
393 let b = Vault::in_memory("svc");
394 let a2 = a.clone();
395
396 a.store_token("user", "token-a")?;
397 assert_eq!(a2.get_token("user")?, Some("token-a".into()));
398 assert_eq!(b.get_token("user")?, None);
399 Ok(())
400 }
401
402 #[test]
405 #[ignore]
406 fn test_keyring_roundtrip_persists_across_instances() -> Result<()> {
407 let service = format!("mcp-airlock-roundtrip-{}", uuid::Uuid::new_v4());
408 let writer = Vault::keyring(&service);
409 writer.store_token("u", "token-123")?;
410 writer.store_dpop_key("u", &[1, 2, 3])?;
411 writer.store_refresh_token("u", "refresh-456")?;
412
413 let reader = Vault::keyring(&service);
415 let result = (
416 reader.get_token("u"),
417 reader.get_dpop_key("u"),
418 reader.get_refresh_token("u"),
419 );
420 reader.delete_token("u")?;
421 reader.delete_dpop_key("u")?;
422 reader.delete_refresh_token("u")?;
423
424 assert_eq!(result.0?, Some("token-123".into()));
425 assert_eq!(result.1?, Some(vec![1, 2, 3]));
426 assert_eq!(result.2?, Some("refresh-456".into()));
427 assert_eq!(reader.get_token("u")?, None);
428 Ok(())
429 }
430
431 #[test]
432 fn test_vault_real_keyring_attempt() {
433 let vault = Vault::keyring("mcp-airlock-unit-test-real");
434
435 let _ = vault.store_token("dummy_user_test", "dummy_token");
438 let _ = vault.get_token("dummy_user_test");
439 let _ = vault.delete_token("dummy_user_test");
440 let _ = vault.store_dpop_key("dummy_user_test", b"dummy");
441 let _ = vault.get_dpop_key("dummy_user_test");
442 let _ = vault.delete_dpop_key("dummy_user_test");
443 }
444}