Skip to main content

sequel_mcp/vault/
keychain.rs

1//! Keychain secret store on macOS (security-framework SecItem API) with
2//! legacy-compatible service naming, this-device-only accessibility, and
3//! no plaintext fallback on any platform.
4
5// Used by the macOS SecItem store below; keep the import out of
6// non-macOS lib builds (unused there → -D warnings).
7#[cfg(target_os = "macos")]
8use crate::app::paths;
9use thiserror::Error;
10use zeroize::Zeroizing;
11
12#[derive(Debug, Error)]
13pub enum SecretStoreError {
14    #[error("secure storage is unsupported on this platform")]
15    Unsupported,
16    #[error("keychain error: {0}")]
17    Keychain(String),
18    #[error("secret not found")]
19    NotFound,
20}
21
22pub trait SecretStore: Send + Sync {
23    fn set_password(
24        &self,
25        connection_name: &str,
26        account: &str,
27        password: &str,
28    ) -> Result<(), SecretStoreError>;
29    fn get_password(
30        &self,
31        connection_name: &str,
32        account: &str,
33    ) -> Result<Zeroizing<String>, SecretStoreError>;
34    fn delete_password(
35        &self,
36        connection_name: &str,
37        account: &str,
38    ) -> Result<bool, SecretStoreError>;
39    fn has_password(&self, connection_name: &str, account: &str) -> bool {
40        self.get_password(connection_name, account).is_ok()
41    }
42}
43
44#[cfg(target_os = "macos")]
45pub use macos::KeychainSecretStore;
46
47#[cfg(target_os = "macos")]
48mod macos {
49    use super::*;
50    use security_framework::access_control::{ProtectionMode, SecAccessControl};
51    use security_framework::passwords::{PasswordOptions, set_generic_password_options};
52
53    /// Direct SecItem-backed store. Every write goes through a fresh item
54    /// with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (implied by the
55    /// access control) so secrets never leave this device; reads find
56    /// pre-existing entries regardless of their attributes.
57    #[derive(Debug, Default, Clone, Copy)]
58    pub struct KeychainSecretStore;
59
60    impl KeychainSecretStore {
61        fn service(&self, connection_name: &str) -> String {
62            paths::keychain_service_name(connection_name)
63        }
64    }
65
66    impl SecretStore for KeychainSecretStore {
67        fn set_password(
68            &self,
69            connection_name: &str,
70            account: &str,
71            password: &str,
72        ) -> Result<(), SecretStoreError> {
73            let service = self.service(connection_name);
74            let _ = security_framework::passwords::delete_generic_password(&service, account);
75            let ac = SecAccessControl::create_with_protection(
76                Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
77                0,
78            )
79            .map_err(|e| SecretStoreError::Keychain(e.to_string()))?;
80            let mut options = PasswordOptions::new_generic_password(&service, account);
81            options.set_access_control(ac);
82            set_generic_password_options(password.as_bytes(), options)
83                .map_err(|e| SecretStoreError::Keychain(e.to_string()))
84        }
85
86        fn get_password(
87            &self,
88            connection_name: &str,
89            account: &str,
90        ) -> Result<Zeroizing<String>, SecretStoreError> {
91            let service = self.service(connection_name);
92            match security_framework::passwords::get_generic_password(&service, account) {
93                Ok(bytes) => Ok(Zeroizing::new(String::from_utf8_lossy(&bytes).into_owned())),
94                Err(e) => match e.code() {
95                    -25300 => Err(SecretStoreError::NotFound),
96                    _ => Err(SecretStoreError::Keychain(e.to_string())),
97                },
98            }
99        }
100
101        fn delete_password(
102            &self,
103            connection_name: &str,
104            account: &str,
105        ) -> Result<bool, SecretStoreError> {
106            let service = self.service(connection_name);
107            match security_framework::passwords::delete_generic_password(&service, account) {
108                Ok(()) => Ok(true),
109                Err(e) => match e.code() {
110                    -25300 => Ok(false),
111                    _ => Err(SecretStoreError::Keychain(e.to_string())),
112                },
113            }
114        }
115    }
116}
117
118/// Non-macOS: no secure storage exists; explicit unsupported error, never
119/// a plaintext file.
120#[cfg(not(target_os = "macos"))]
121#[derive(Debug, Default, Clone, Copy)]
122pub struct UnsupportedSecretStore;
123
124#[cfg(not(target_os = "macos"))]
125impl SecretStore for UnsupportedSecretStore {
126    fn set_password(
127        &self,
128        _connection_name: &str,
129        _account: &str,
130        _password: &str,
131    ) -> Result<(), SecretStoreError> {
132        Err(SecretStoreError::Unsupported)
133    }
134    fn get_password(
135        &self,
136        _connection_name: &str,
137        _account: &str,
138    ) -> Result<Zeroizing<String>, SecretStoreError> {
139        Err(SecretStoreError::Unsupported)
140    }
141    fn delete_password(
142        &self,
143        _connection_name: &str,
144        _account: &str,
145    ) -> Result<bool, SecretStoreError> {
146        Err(SecretStoreError::Unsupported)
147    }
148}
149
150/// Platform default store: macOS Keychain, explicit unsupported
151/// elsewhere. In test mode the production Keychain is unavailable — an
152/// in-memory store (optionally seeded from `SEQUEL_MCP_TEST_SECRETS`)
153/// is used instead, so tests and benchmarks can never read or write the
154/// developer's real Keychain entries.
155pub fn default_store() -> std::sync::Arc<dyn SecretStore> {
156    if crate::app::test_mode::is_active() {
157        return crate::app::test_mode::secret_store();
158    }
159    #[cfg(target_os = "macos")]
160    {
161        std::sync::Arc::new(KeychainSecretStore)
162    }
163    #[cfg(not(target_os = "macos"))]
164    {
165        std::sync::Arc::new(UnsupportedSecretStore)
166    }
167}
168
169/// In-memory store for tests and tooling. Never touches the real Keychain
170/// and therefore never triggers a consent dialog.
171#[derive(Default)]
172pub struct InMemorySecretStore {
173    entries:
174        std::sync::Mutex<std::collections::HashMap<(String, String), zeroize::Zeroizing<String>>>,
175}
176
177impl InMemorySecretStore {
178    pub fn new() -> Self {
179        Self::default()
180    }
181}
182
183impl SecretStore for InMemorySecretStore {
184    fn set_password(
185        &self,
186        connection_name: &str,
187        account: &str,
188        password: &str,
189    ) -> Result<(), SecretStoreError> {
190        self.entries.lock().unwrap().insert(
191            (connection_name.to_string(), account.to_string()),
192            Zeroizing::new(password.to_string()),
193        );
194        Ok(())
195    }
196
197    fn get_password(
198        &self,
199        connection_name: &str,
200        account: &str,
201    ) -> Result<Zeroizing<String>, SecretStoreError> {
202        self.entries
203            .lock()
204            .unwrap()
205            .get(&(connection_name.to_string(), account.to_string()))
206            .map(|v| Zeroizing::new(v.to_string()))
207            .ok_or(SecretStoreError::NotFound)
208    }
209
210    fn delete_password(
211        &self,
212        connection_name: &str,
213        account: &str,
214    ) -> Result<bool, SecretStoreError> {
215        Ok(self
216            .entries
217            .lock()
218            .unwrap()
219            .remove(&(connection_name.to_string(), account.to_string()))
220            .is_some())
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    // Only the non-macOS fallback test needs parent items; importing
227    // them unconditionally would be an unused import on macOS.
228    #[cfg(not(target_os = "macos"))]
229    use super::{SecretStore, SecretStoreError, UnsupportedSecretStore};
230
231    #[cfg(not(target_os = "macos"))]
232    #[test]
233    fn unsupported_platform_fails_explicitly() {
234        let s = UnsupportedSecretStore;
235        assert!(matches!(
236            s.set_password("c", "a", "p"),
237            Err(SecretStoreError::Unsupported)
238        ));
239        assert!(matches!(
240            s.get_password("c", "a"),
241            Err(SecretStoreError::Unsupported)
242        ));
243    }
244
245    #[test]
246    fn service_names_match_legacy() {
247        assert_eq!(
248            crate::app::paths::keychain_service_name("prod"),
249            "sequel-mcp : prod"
250        );
251    }
252}