1#[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 #[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#[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
150pub 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#[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 #[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}