skill_context/providers/
keychain.rs

1//! Platform keychain secret provider.
2//!
3//! Uses the system keychain for secure secret storage:
4//! - macOS: Keychain
5//! - Windows: Credential Manager
6//! - Linux: Secret Service (via DBus)
7
8use async_trait::async_trait;
9use keyring::Entry;
10use zeroize::Zeroizing;
11
12use super::{SecretProvider, SecretValue};
13use crate::ContextError;
14
15/// Service name used for keyring entries.
16const SERVICE_NAME: &str = "skill-engine-context";
17
18/// Secret provider that uses the platform keychain.
19pub struct KeychainProvider {
20    /// Optional prefix for all keys.
21    prefix: Option<String>,
22}
23
24impl KeychainProvider {
25    /// Create a new keychain provider.
26    pub fn new() -> Self {
27        Self { prefix: None }
28    }
29
30    /// Create a new keychain provider with a key prefix.
31    pub fn with_prefix(prefix: impl Into<String>) -> Self {
32        Self {
33            prefix: Some(prefix.into()),
34        }
35    }
36
37    /// Build the keyring user/key identifier.
38    fn build_key(&self, context_id: &str, key: &str) -> String {
39        match &self.prefix {
40            Some(p) => format!("{}/{}/{}/{}", p, SERVICE_NAME, context_id, key),
41            None => format!("{}/{}/{}", SERVICE_NAME, context_id, key),
42        }
43    }
44
45    /// Get a keyring entry.
46    fn get_entry(&self, context_id: &str, key: &str) -> Result<Entry, ContextError> {
47        let user = self.build_key(context_id, key);
48        Entry::new(SERVICE_NAME, &user).map_err(|e| {
49            ContextError::SecretProvider(format!("Failed to create keyring entry: {}", e))
50        })
51    }
52}
53
54impl Default for KeychainProvider {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60#[async_trait]
61impl SecretProvider for KeychainProvider {
62    async fn get_secret(
63        &self,
64        context_id: &str,
65        key: &str,
66    ) -> Result<Option<SecretValue>, ContextError> {
67        let entry = self.get_entry(context_id, key)?;
68
69        match entry.get_password() {
70            Ok(password) => {
71                tracing::debug!(
72                    context_id = context_id,
73                    key = key,
74                    "Retrieved secret from keychain"
75                );
76                Ok(Some(Zeroizing::new(password)))
77            }
78            Err(keyring::Error::NoEntry) => Ok(None),
79            Err(e) => {
80                tracing::warn!(
81                    context_id = context_id,
82                    key = key,
83                    error = %e,
84                    "Failed to get secret from keychain"
85                );
86                Err(ContextError::SecretProvider(format!(
87                    "Failed to get secret '{}' from keychain: {}",
88                    key, e
89                )))
90            }
91        }
92    }
93
94    async fn set_secret(
95        &self,
96        context_id: &str,
97        key: &str,
98        value: &str,
99    ) -> Result<(), ContextError> {
100        let entry = self.get_entry(context_id, key)?;
101
102        entry.set_password(value).map_err(|e| {
103            tracing::error!(
104                context_id = context_id,
105                key = key,
106                error = %e,
107                "Failed to set secret in keychain"
108            );
109            ContextError::SecretProvider(format!(
110                "Failed to set secret '{}' in keychain: {}",
111                key, e
112            ))
113        })?;
114
115        tracing::info!(
116            context_id = context_id,
117            key = key,
118            "Stored secret in keychain"
119        );
120
121        Ok(())
122    }
123
124    async fn delete_secret(&self, context_id: &str, key: &str) -> Result<(), ContextError> {
125        let entry = self.get_entry(context_id, key)?;
126
127        match entry.delete_credential() {
128            Ok(()) => {
129                tracing::info!(
130                    context_id = context_id,
131                    key = key,
132                    "Deleted secret from keychain"
133                );
134                Ok(())
135            }
136            Err(keyring::Error::NoEntry) => {
137                // Already doesn't exist, that's fine
138                Ok(())
139            }
140            Err(e) => {
141                tracing::error!(
142                    context_id = context_id,
143                    key = key,
144                    error = %e,
145                    "Failed to delete secret from keychain"
146                );
147                Err(ContextError::SecretProvider(format!(
148                    "Failed to delete secret '{}' from keychain: {}",
149                    key, e
150                )))
151            }
152        }
153    }
154
155    async fn list_keys(&self, _context_id: &str) -> Result<Vec<String>, ContextError> {
156        // The keyring crate doesn't support listing keys
157        // This would require platform-specific implementations
158        tracing::warn!("Listing keys is not supported by the keychain provider");
159        Ok(Vec::new())
160    }
161
162    fn name(&self) -> &'static str {
163        "keychain"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    // Note: These tests interact with the real system keychain
172    // They're marked as ignored by default to avoid polluting the keychain
173
174    #[tokio::test]
175    #[ignore = "interacts with system keychain"]
176    async fn test_keychain_set_get_delete() {
177        let provider = KeychainProvider::new();
178        let context_id = "test-context";
179        let key = "test-secret-key";
180        let value = "super-secret-value";
181
182        // Set
183        provider.set_secret(context_id, key, value).await.unwrap();
184
185        // Get
186        let retrieved = provider.get_secret(context_id, key).await.unwrap();
187        assert!(retrieved.is_some());
188        assert_eq!(&*retrieved.unwrap(), value);
189
190        // Delete
191        provider.delete_secret(context_id, key).await.unwrap();
192
193        // Verify deleted
194        let retrieved = provider.get_secret(context_id, key).await.unwrap();
195        assert!(retrieved.is_none());
196    }
197
198    #[tokio::test]
199    async fn test_keychain_get_nonexistent() {
200        let provider = KeychainProvider::new();
201        let result = provider
202            .get_secret("nonexistent-context", "nonexistent-key")
203            .await;
204
205        // Should return Ok(None), not an error
206        assert!(result.is_ok());
207        assert!(result.unwrap().is_none());
208    }
209
210    #[test]
211    fn test_key_building() {
212        let provider = KeychainProvider::new();
213        let key = provider.build_key("my-context", "api-key");
214        assert_eq!(key, "skill-engine-context/my-context/api-key");
215
216        let provider_with_prefix = KeychainProvider::with_prefix("custom");
217        let key = provider_with_prefix.build_key("my-context", "api-key");
218        assert_eq!(key, "custom/skill-engine-context/my-context/api-key");
219    }
220}