Skip to main content

max / goingson

15.9 KB · 477 lines History Blame Raw
1 //! Secure credential storage using OS keychain.
2 //!
3 //! Uses the system's secure credential storage:
4 //! - macOS: Keychain
5 //! - Windows: Credential Manager
6 //! - Linux: Secret Service (via D-Bus)
7 //!
8 //! Credentials are stored with a service name of "goingson" and
9 //! a unique key per account based on the account ID.
10
11 use keyring::Entry;
12 use serde::{Deserialize, Serialize};
13 use tracing::{debug, error, info, warn};
14 use uuid::Uuid;
15
16 const SERVICE_NAME: &str = "goingson";
17
18 /// Credentials stored in the keychain for an OAuth account.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 pub struct OAuthCredentials {
21 pub access_token: String,
22 pub refresh_token: Option<String>,
23 }
24
25 /// Credentials stored in the keychain for a password-based email account.
26 #[derive(Debug, Clone, Serialize, Deserialize)]
27 pub struct PasswordCredentials {
28 pub password: String,
29 }
30
31 /// Credentials stored in the keychain for a SyncKit sync session.
32 #[derive(Debug, Clone, Serialize, Deserialize)]
33 pub struct SyncTokenCredentials {
34 pub token: String,
35 pub user_id: Uuid,
36 pub app_id: Uuid,
37 }
38
39 /// Secure credential storage manager.
40 pub struct CredentialStore;
41
42 impl CredentialStore {
43 /// Generate the keychain key for an account's OAuth tokens.
44 fn oauth_key(account_id: Uuid) -> String {
45 format!("oauth:{}", account_id)
46 }
47
48 /// Generate the keychain key for an account's password.
49 fn password_key(account_id: Uuid) -> String {
50 format!("password:{}", account_id)
51 }
52
53 /// Store OAuth credentials for an account.
54 ///
55 /// # Arguments
56 /// * `account_id` - The email account UUID
57 /// * `credentials` - The OAuth tokens to store
58 ///
59 /// # Returns
60 /// Ok(()) on success, Err with message on failure
61 pub fn store_oauth(account_id: Uuid, credentials: &OAuthCredentials) -> Result<(), String> {
62 let key = Self::oauth_key(account_id);
63 let entry = Entry::new(SERVICE_NAME, &key)
64 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
65
66 let json = serde_json::to_string(credentials)
67 .map_err(|e| format!("Failed to serialize credentials: {}", e))?;
68
69 entry
70 .set_password(&json)
71 .map_err(|e| format!("Failed to store in keychain: {}", e))?;
72
73 debug!("Stored OAuth credentials for account {}", account_id);
74 Ok(())
75 }
76
77 /// Retrieve OAuth credentials for an account.
78 ///
79 /// # Arguments
80 /// * `account_id` - The email account UUID
81 ///
82 /// # Returns
83 /// Some(credentials) if found, None if not in keychain
84 pub fn get_oauth(account_id: Uuid) -> Option<OAuthCredentials> {
85 let key = Self::oauth_key(account_id);
86 let entry = match Entry::new(SERVICE_NAME, &key) {
87 Ok(e) => e,
88 Err(e) => {
89 warn!("Failed to create keychain entry for read: {}", e);
90 return None;
91 }
92 };
93
94 match entry.get_password() {
95 Ok(json) => match serde_json::from_str(&json) {
96 Ok(creds) => {
97 debug!("Retrieved OAuth credentials for account {}", account_id);
98 Some(creds)
99 }
100 Err(e) => {
101 error!("Failed to deserialize credentials: {}", e);
102 None
103 }
104 },
105 Err(keyring::Error::NoEntry) => {
106 debug!("No OAuth credentials found for account {}", account_id);
107 None
108 }
109 Err(e) => {
110 warn!("Failed to retrieve from keychain: {}", e);
111 None
112 }
113 }
114 }
115
116 /// Update OAuth tokens for an account (typically after refresh).
117 ///
118 /// # Arguments
119 /// * `account_id` - The email account UUID
120 /// * `access_token` - New access token
121 /// * `refresh_token` - New refresh token (if provided by the OAuth server)
122 pub fn update_oauth_tokens(
123 account_id: Uuid,
124 access_token: &str,
125 refresh_token: Option<&str>,
126 ) -> Result<(), String> {
127 // Get existing credentials to preserve refresh token if not updated
128 let existing = Self::get_oauth(account_id);
129 let refresh = refresh_token
130 .map(String::from)
131 .or_else(|| existing.and_then(|c| c.refresh_token));
132
133 let credentials = OAuthCredentials {
134 access_token: access_token.to_string(),
135 refresh_token: refresh,
136 };
137
138 Self::store_oauth(account_id, &credentials)
139 }
140
141 /// Delete OAuth credentials for an account.
142 ///
143 /// # Arguments
144 /// * `account_id` - The email account UUID
145 pub fn delete_oauth(account_id: Uuid) -> Result<(), String> {
146 let key = Self::oauth_key(account_id);
147 let entry = Entry::new(SERVICE_NAME, &key)
148 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
149
150 match entry.delete_credential() {
151 Ok(()) => {
152 debug!("Deleted OAuth credentials for account {}", account_id);
153 Ok(())
154 }
155 Err(keyring::Error::NoEntry) => {
156 debug!("No OAuth credentials to delete for account {}", account_id);
157 Ok(())
158 }
159 Err(e) => Err(format!("Failed to delete from keychain: {}", e)),
160 }
161 }
162
163 /// Store password credentials for an account.
164 ///
165 /// # Arguments
166 /// * `account_id` - The email account UUID
167 /// * `password` - The password to store
168 pub fn store_password(account_id: Uuid, password: &str) -> Result<(), String> {
169 let key = Self::password_key(account_id);
170 let entry = Entry::new(SERVICE_NAME, &key)
171 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
172
173 let credentials = PasswordCredentials {
174 password: password.to_string(),
175 };
176 let json = serde_json::to_string(&credentials)
177 .map_err(|e| format!("Failed to serialize credentials: {}", e))?;
178
179 entry
180 .set_password(&json)
181 .map_err(|e| format!("Failed to store in keychain: {}", e))?;
182
183 debug!("Stored password credentials for account {}", account_id);
184 Ok(())
185 }
186
187 /// Retrieve password credentials for an account.
188 ///
189 /// # Arguments
190 /// * `account_id` - The email account UUID
191 ///
192 /// # Returns
193 /// Some(password) if found, None if not in keychain
194 pub fn get_password(account_id: Uuid) -> Option<String> {
195 let key = Self::password_key(account_id);
196 let entry = match Entry::new(SERVICE_NAME, &key) {
197 Ok(e) => e,
198 Err(e) => {
199 warn!("Failed to create keychain entry for read: {}", e);
200 return None;
201 }
202 };
203
204 match entry.get_password() {
205 Ok(json) => match serde_json::from_str::<PasswordCredentials>(&json) {
206 Ok(creds) => {
207 debug!("Retrieved password for account {}", account_id);
208 Some(creds.password)
209 }
210 Err(e) => {
211 error!("Failed to deserialize password: {}", e);
212 None
213 }
214 },
215 Err(keyring::Error::NoEntry) => {
216 debug!("No password found for account {}", account_id);
217 None
218 }
219 Err(e) => {
220 warn!("Failed to retrieve from keychain: {}", e);
221 None
222 }
223 }
224 }
225
226 /// Delete password credentials for an account.
227 ///
228 /// # Arguments
229 /// * `account_id` - The email account UUID
230 pub fn delete_password(account_id: Uuid) -> Result<(), String> {
231 let key = Self::password_key(account_id);
232 let entry = Entry::new(SERVICE_NAME, &key)
233 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
234
235 match entry.delete_credential() {
236 Ok(()) => {
237 debug!("Deleted password for account {}", account_id);
238 Ok(())
239 }
240 Err(keyring::Error::NoEntry) => {
241 debug!("No password to delete for account {}", account_id);
242 Ok(())
243 }
244 Err(e) => Err(format!("Failed to delete from keychain: {}", e)),
245 }
246 }
247
248 // ============ Sync Token Methods ============
249
250 /// Fixed keychain key for the SyncKit sync token (single-user, single sync account).
251 fn sync_key() -> String {
252 "sync:token".to_string()
253 }
254
255 /// Store SyncKit sync token and session info in the keychain.
256 pub fn store_sync_token(token: &str, user_id: Uuid, app_id: Uuid) -> Result<(), String> {
257 let key = Self::sync_key();
258 let entry = Entry::new(SERVICE_NAME, &key)
259 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
260
261 let creds = SyncTokenCredentials {
262 token: token.to_string(),
263 user_id,
264 app_id,
265 };
266 let json = serde_json::to_string(&creds)
267 .map_err(|e| format!("Failed to serialize sync credentials: {}", e))?;
268
269 entry
270 .set_password(&json)
271 .map_err(|e| format!("Failed to store sync token in keychain: {} ({:?})", e, e))?;
272
273 info!("Stored sync token in keychain for user {}", user_id);
274 Ok(())
275 }
276
277 /// Retrieve SyncKit sync token and session info from the keychain.
278 pub fn get_sync_token() -> Option<SyncTokenCredentials> {
279 let key = Self::sync_key();
280 let entry = match Entry::new(SERVICE_NAME, &key) {
281 Ok(e) => e,
282 Err(e) => {
283 warn!("Failed to create keychain entry for sync token read: {}", e);
284 return None;
285 }
286 };
287
288 match entry.get_password() {
289 Ok(json) => match serde_json::from_str(&json) {
290 Ok(creds) => {
291 debug!("Retrieved sync token from keychain");
292 Some(creds)
293 }
294 Err(e) => {
295 error!("Failed to deserialize sync token: {}", e);
296 None
297 }
298 },
299 Err(keyring::Error::NoEntry) => {
300 info!("No sync token in keychain (NoEntry)");
301 None
302 }
303 Err(e) => {
304 warn!("Keychain sync token retrieval failed: {} ({:?})", e, e);
305 None
306 }
307 }
308 }
309
310 /// Delete SyncKit sync token from the keychain.
311 pub fn delete_sync_token() -> Result<(), String> {
312 let key = Self::sync_key();
313 let entry = Entry::new(SERVICE_NAME, &key)
314 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
315
316 match entry.delete_credential() {
317 Ok(()) => {
318 debug!("Deleted sync token from keychain");
319 Ok(())
320 }
321 Err(keyring::Error::NoEntry) => {
322 debug!("No sync token to delete");
323 Ok(())
324 }
325 Err(e) => Err(format!("Failed to delete sync token from keychain: {}", e)),
326 }
327 }
328
329 // ============ Sync API Key Methods ============
330
331 /// Fixed keychain key for the sync API key.
332 fn sync_api_key() -> String {
333 "sync:api_key".to_string()
334 }
335
336 /// Store a sync API key in the keychain.
337 pub fn store_sync_api_key(api_key: &str) -> Result<(), String> {
338 let key = Self::sync_api_key();
339 let entry = Entry::new(SERVICE_NAME, &key)
340 .map_err(|e| format!("Failed to create keychain entry: {}", e))?;
341
342 entry
343 .set_password(api_key)
344 .map_err(|e| format!("Failed to store sync API key in keychain: {}", e))?;
345
346 debug!("Stored sync API key in keychain");
347 Ok(())
348 }
349
350 /// Retrieve the sync API key from the keychain.
351 pub fn get_sync_api_key() -> Option<String> {
352 let key = Self::sync_api_key();
353 let entry = match Entry::new(SERVICE_NAME, &key) {
354 Ok(e) => e,
355 Err(e) => {
356 warn!("Failed to create keychain entry for sync API key read: {}", e);
357 return None;
358 }
359 };
360
361 match entry.get_password() {
362 Ok(api_key) => {
363 debug!("Retrieved sync API key from keychain");
364 Some(api_key)
365 }
366 Err(keyring::Error::NoEntry) => None,
367 Err(e) => {
368 warn!("Failed to retrieve sync API key from keychain: {}", e);
369 None
370 }
371 }
372 }
373
374 /// Migrate credentials from database to keychain.
375 ///
376 /// Call this on startup to move any plaintext credentials
377 /// from SQLite to the secure keychain.
378 ///
379 /// # Arguments
380 /// * `account_id` - The email account UUID
381 /// * `oauth_access` - Access token from database (if any)
382 /// * `oauth_refresh` - Refresh token from database (if any)
383 /// * `password` - Password from database (if any)
384 ///
385 /// # Returns
386 /// true if any credentials were migrated
387 pub fn migrate_from_database(
388 account_id: Uuid,
389 oauth_access: Option<&str>,
390 oauth_refresh: Option<&str>,
391 password: Option<&str>,
392 ) -> bool {
393 let mut migrated = false;
394
395 // Migrate OAuth tokens
396 if let Some(access) = oauth_access {
397 if !access.is_empty() && Self::get_oauth(account_id).is_none() {
398 let creds = OAuthCredentials {
399 access_token: access.to_string(),
400 refresh_token: oauth_refresh.map(String::from),
401 };
402 if Self::store_oauth(account_id, &creds).is_ok() {
403 debug!("Migrated OAuth credentials for account {}", account_id);
404 migrated = true;
405 }
406 }
407 }
408
409 // Migrate password
410 if let Some(pwd) = password {
411 if !pwd.is_empty() && Self::get_password(account_id).is_none()
412 && Self::store_password(account_id, pwd).is_ok() {
413 debug!("Migrated password for account {}", account_id);
414 migrated = true;
415 }
416 }
417
418 migrated
419 }
420 }
421
422 #[cfg(test)]
423 mod tests {
424 use super::*;
425
426 // Note: These tests interact with the real system keychain.
427 // They use a test-specific UUID to avoid conflicts.
428
429 #[test]
430 #[ignore] // Requires macOS keychain — fails on Linux CI
431 fn test_oauth_roundtrip() {
432 let test_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
433
434 // Clean up any existing test data
435 let _ = CredentialStore::delete_oauth(test_id);
436
437 // Store credentials
438 let creds = OAuthCredentials {
439 access_token: "test_access_token".to_string(),
440 refresh_token: Some("test_refresh_token".to_string()),
441 };
442 CredentialStore::store_oauth(test_id, &creds).expect("Failed to store");
443
444 // Retrieve and verify
445 let retrieved = CredentialStore::get_oauth(test_id).expect("Failed to retrieve");
446 assert_eq!(retrieved.access_token, "test_access_token");
447 assert_eq!(
448 retrieved.refresh_token,
449 Some("test_refresh_token".to_string())
450 );
451
452 // Clean up
453 CredentialStore::delete_oauth(test_id).expect("Failed to delete");
454 assert!(CredentialStore::get_oauth(test_id).is_none());
455 }
456
457 #[test]
458 #[ignore] // Requires macOS keychain — fails on Linux CI
459 fn test_password_roundtrip() {
460 let test_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
461
462 // Clean up any existing test data
463 let _ = CredentialStore::delete_password(test_id);
464
465 // Store password
466 CredentialStore::store_password(test_id, "test_password").expect("Failed to store");
467
468 // Retrieve and verify
469 let retrieved = CredentialStore::get_password(test_id).expect("Failed to retrieve");
470 assert_eq!(retrieved, "test_password");
471
472 // Clean up
473 CredentialStore::delete_password(test_id).expect("Failed to delete");
474 assert!(CredentialStore::get_password(test_id).is_none());
475 }
476 }
477