Skip to main content

ynab_mcp/
secrets.rs

1use crate::error::{Result, YnabMcpError};
2use keyring::{Entry, Error as KeyringError};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::env;
6use std::fmt;
7use tracing::{debug, info, warn};
8use zeroize::Zeroize;
9
10/// Service name for the keyring entry
11const SERVICE_NAME: &str = "ynab-mcp";
12
13/// User name for the keyring entry  
14const USER_NAME: &str = "api-token";
15
16/// Configuration for secrets management
17#[derive(Debug, Clone, Copy)]
18pub struct SecretsConfig {
19    /// Whether to use keyring storage (OS-native secure storage)
20    pub use_keyring: bool,
21    /// Whether to automatically migrate from env vars to keyring
22    pub auto_migrate: bool,
23    /// Whether to require secure storage (fail if keyring unavailable)
24    pub require_secure_storage: bool,
25}
26
27impl Default for SecretsConfig {
28    fn default() -> Self {
29        Self {
30            use_keyring: true,
31            auto_migrate: true,
32            require_secure_storage: false, // Don't break existing deployments
33        }
34    }
35}
36
37/// Secure wrapper for the YNAB API token
38#[derive(Clone, Zeroize)]
39pub struct YnabApiToken {
40    #[zeroize(skip)]
41    inner: SecretString,
42}
43
44impl YnabApiToken {
45    /// Create a new token from a string
46    pub fn new(token: String) -> Self {
47        Self {
48            inner: SecretString::new(token.into()),
49        }
50    }
51
52    /// Expose the secret for API calls (use sparingly!)
53    pub fn expose_secret(&self) -> &str {
54        self.inner.expose_secret()
55    }
56
57    /// Check if the token is empty
58    pub fn is_empty(&self) -> bool {
59        self.inner.expose_secret().is_empty()
60    }
61
62    /// Validate the token format
63    pub fn validate(&self) -> Result<()> {
64        let token = self.inner.expose_secret();
65
66        if token.is_empty() {
67            return Err(YnabMcpError::authentication(
68                "YNAB API token is missing or empty",
69                "Set your YNAB API token using: 1) Environment variable YNAB_API_TOKEN, 2) Run 'ynab-mcp store-token' to store securely in keyring, or 3) Get a token from https://app.youneedabudget.com/settings/developer"
70            ));
71        }
72
73        // YNAB tokens typically start with a pattern, but we'll be permissive
74        if token.len() < 10 {
75            return Err(YnabMcpError::validation(
76                &format!("YNAB API token is too short ({} characters)", token.len()),
77                "ynab_api_token",
78                "at least 10 characters (typically 43+ characters for real YNAB tokens)",
79                "Check your token at https://app.youneedabudget.com/settings/developer and ensure you copied the complete token"
80            ));
81        }
82
83        // Check for common test/placeholder tokens
84        let is_test_token = Self::is_test_token(token);
85        if is_test_token {
86            let env_block_test = std::env::var("YNAB_BLOCK_TEST_TOKENS")
87                .map(|v| v.to_lowercase() == "true" || v == "1")
88                .unwrap_or(false);
89
90            if env_block_test {
91                return Err(YnabMcpError::authentication(
92                    "Test/placeholder token detected and blocked in production mode",
93                    "Replace with a real YNAB API token from https://app.youneedabudget.com/settings/developer or set YNAB_BLOCK_TEST_TOKENS=false for testing"
94                ));
95            } else {
96                warn!("Token appears to be a test/placeholder token - set YNAB_BLOCK_TEST_TOKENS=true to block in production");
97            }
98        }
99
100        Ok(())
101    }
102
103    /// Check if a token appears to be a test/placeholder token
104    pub fn is_test_token(token: &str) -> bool {
105        let test_patterns = [
106            "test",
107            "dummy",
108            "placeholder",
109            "example",
110            "fake",
111            "demo",
112            "sample",
113            "mock",
114            "ynab_api_token",
115            "your_token_here",
116            "xxx",
117        ];
118        let lower_token = token.to_lowercase();
119
120        // Check for test patterns
121        for pattern in &test_patterns {
122            if lower_token.contains(pattern) {
123                return true;
124            }
125        }
126
127        // Check for repeated characters (like "aaaa" or "1111")
128        if token.len() > 4 {
129            if let Some(first_char) = token.chars().next() {
130                if token.chars().all(|c| c == first_char) {
131                    return true;
132                }
133            }
134        }
135
136        // Check for sequential patterns
137        if token == "abcdefghijklmnopqrstuvwxyz" || token == "123456789" {
138            return true;
139        }
140
141        false
142    }
143}
144
145impl fmt::Debug for YnabApiToken {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.debug_struct("YnabApiToken")
148            .field("inner", &"[REDACTED]")
149            .finish()
150    }
151}
152
153impl fmt::Display for YnabApiToken {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(f, "[REDACTED TOKEN]")
156    }
157}
158
159// Custom serde implementation to prevent accidental serialization
160impl Serialize for YnabApiToken {
161    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
162    where
163        S: Serializer,
164    {
165        serializer.serialize_str("[REDACTED]")
166    }
167}
168
169impl<'de> Deserialize<'de> for YnabApiToken {
170    fn deserialize<D>(_deserializer: D) -> std::result::Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        // Never deserialize tokens - force explicit construction
175        Err(serde::de::Error::custom(
176            "YnabApiToken cannot be deserialized - must be constructed explicitly",
177        ))
178    }
179}
180
181/// Secrets manager for YNAB API tokens
182pub struct SecretsManager {
183    config: SecretsConfig,
184    keyring_entry: Option<Entry>,
185}
186
187impl SecretsManager {
188    /// Create a new secrets manager
189    pub fn new(config: SecretsConfig) -> Result<Self> {
190        let keyring_entry = if config.use_keyring {
191            match Entry::new(SERVICE_NAME, USER_NAME) {
192                Ok(entry) => {
193                    info!("Keyring available: {}", Self::get_keyring_info());
194                    Some(entry)
195                }
196                Err(e) => {
197                    if config.require_secure_storage {
198                        return Err(YnabMcpError::config(
199                            &format!("Keyring required but unavailable: {}", e),
200                            "Install and configure a keyring service on your system, or set REQUIRE_SECURE_STORAGE=false to allow environment variable storage"
201                        ));
202                    } else {
203                        warn!(
204                            "Keyring unavailable, falling back to environment variables: {}",
205                            e
206                        );
207                        None
208                    }
209                }
210            }
211        } else {
212            debug!("Keyring disabled by configuration");
213            None
214        };
215
216        Ok(Self {
217            config,
218            keyring_entry,
219        })
220    }
221
222    /// Get information about the available keyring backend
223    fn get_keyring_info() -> String {
224        // This would be platform-specific, but keyring crate doesn't expose this easily
225        if cfg!(target_os = "macos") {
226            "macOS Keychain".to_string()
227        } else if cfg!(target_os = "windows") {
228            "Windows Credential Manager".to_string()
229        } else if cfg!(target_os = "linux") {
230            "Linux Kernel Keyring (keyutils)".to_string()
231        } else {
232            "Generic keyring backend".to_string()
233        }
234    }
235
236    /// Load the YNAB API token from the most secure available source
237    pub fn load_token(&self) -> Result<YnabApiToken> {
238        // Try keyring first if available
239        if let Some(ref entry) = self.keyring_entry {
240            match entry.get_password() {
241                Ok(token) => {
242                    debug!("Token loaded from keyring");
243                    return Ok(YnabApiToken::new(token));
244                }
245                Err(KeyringError::NoEntry) => {
246                    debug!("No token found in keyring");
247                    // Continue to environment variables
248                }
249                Err(e) => {
250                    warn!("Failed to read from keyring: {}", e);
251                    if self.config.require_secure_storage {
252                        return Err(YnabMcpError::authentication(
253                            &format!("Failed to read from keyring: {}", e),
254                            "Check that keyring service is running and accessible. On Linux, ensure gnome-keyring or kwallet is available. Set REQUIRE_SECURE_STORAGE=false to use environment variables instead."
255                        ));
256                    }
257                    // Continue to environment variables
258                }
259            }
260        }
261
262        // Fall back to environment variables
263        let token = env::var("YNAB_API_TOKEN")
264            .or_else(|_| env::var("YNAB_ACCESS_TOKEN"))
265            .map_err(|_| YnabMcpError::authentication(
266                "YNAB API token not found",
267                "Set your YNAB API token using environment variable YNAB_API_TOKEN or run 'ynab-mcp store-token' to store it securely. Get a token from https://app.youneedabudget.com/settings/developer"
268            ))?;
269
270        debug!("Token loaded from environment variables");
271
272        let ynab_token = YnabApiToken::new(token);
273
274        // Auto-migrate to keyring if enabled
275        if self.config.auto_migrate && self.keyring_entry.is_some() {
276            if let Err(e) = self.store_token(&ynab_token) {
277                warn!(
278                    operation = "token_migration",
279                    result = "failed",
280                    error = %e,
281                    "Failed to migrate token to keyring"
282                );
283            } else {
284                info!(
285                    operation = "token_migration",
286                    result = "success",
287                    "Token automatically migrated to keyring for enhanced security"
288                );
289                info!(
290                    operation = "token_migration",
291                    cleanup_hint = "env_var_removal",
292                    "You can now remove YNAB_API_TOKEN from environment variables"
293                );
294            }
295        }
296
297        Ok(ynab_token)
298    }
299
300    /// Store a token in the keyring
301    pub fn store_token(&self, token: &YnabApiToken) -> Result<()> {
302        let entry = self
303            .keyring_entry
304            .as_ref()
305            .ok_or_else(|| YnabMcpError::config(
306                "Keyring is not available on this system",
307                "Install and configure a keyring service (gnome-keyring, kwallet, or Windows Credential Manager) or set USE_KEYRING=false to use environment variables"
308            ))?;
309
310        entry
311            .set_password(token.expose_secret())
312            .map_err(|e| YnabMcpError::authentication(
313                &format!("Failed to write to keyring: {}", e),
314                "Ensure keyring service is running and has write permissions. Check that the keyring is unlocked and accessible."
315            ))?;
316
317        info!("Token stored in keyring successfully");
318        Ok(())
319    }
320
321    /// Delete the token from keyring
322    pub fn delete_token(&self) -> Result<()> {
323        let entry = self
324            .keyring_entry
325            .as_ref()
326            .ok_or_else(|| YnabMcpError::config(
327                "Keyring is not available on this system",
328                "Install and configure a keyring service (gnome-keyring, kwallet, or Windows Credential Manager) or set USE_KEYRING=false to use environment variables"
329            ))?;
330
331        match entry.delete_credential() {
332            Ok(()) => {
333                info!("Token deleted from keyring");
334                Ok(())
335            }
336            Err(KeyringError::NoEntry) => {
337                debug!("No token found in keyring to delete");
338                Ok(())
339            }
340            Err(e) => Err(YnabMcpError::authentication(
341                &format!("Failed to delete token from keyring: {}", e),
342                "Check that keyring service is accessible and the token exists. The keyring may be locked or the service may be unavailable."
343            )),
344        }
345    }
346
347    /// Check if keyring is available
348    pub fn is_keyring_available(&self) -> bool {
349        self.keyring_entry.is_some()
350    }
351
352    /// Get the current storage method being used
353    pub fn get_storage_method(&self) -> StorageMethod {
354        if self.keyring_entry.is_some() {
355            // Check if there's actually a token in the keyring
356            if let Some(ref entry) = self.keyring_entry {
357                match entry.get_password() {
358                    Ok(_) => StorageMethod::Keyring,
359                    Err(_) => StorageMethod::EnvironmentVariable,
360                }
361            } else {
362                StorageMethod::EnvironmentVariable
363            }
364        } else {
365            StorageMethod::EnvironmentVariable
366        }
367    }
368
369    /// Provide setup guidance for users
370    pub fn setup_guidance(&self) -> Vec<String> {
371        let mut guidance = Vec::new();
372
373        if !self.is_keyring_available() {
374            guidance.push("⚠️  Keyring unavailable - using environment variables".to_string());
375            guidance.push(
376                "   For enhanced security, ensure your system supports keyring storage".to_string(),
377            );
378        } else if self.get_storage_method() == StorageMethod::EnvironmentVariable {
379            guidance.push(
380                "💡 You can enhance security by storing your token in the system keyring:"
381                    .to_string(),
382            );
383            guidance.push("   1. Run: ynab-mcp --store-token".to_string());
384            guidance.push("   2. Remove YNAB_API_TOKEN from environment variables".to_string());
385        } else {
386            guidance.push("✅ Token is securely stored in system keyring".to_string());
387        }
388
389        guidance
390    }
391}
392
393/// Represents the method used to store the token
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub enum StorageMethod {
396    /// Token stored in OS-native keyring (most secure)
397    Keyring,
398    /// Token stored in environment variables (less secure)
399    EnvironmentVariable,
400}
401
402impl fmt::Display for StorageMethod {
403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404        match self {
405            StorageMethod::Keyring => write!(f, "System Keyring"),
406            StorageMethod::EnvironmentVariable => write!(f, "Environment Variables"),
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_token_creation() {
417        let token = YnabApiToken::new("test-token-123".to_string());
418        assert_eq!(token.expose_secret(), "test-token-123");
419        assert!(!token.is_empty());
420    }
421
422    #[test]
423    fn test_empty_token() {
424        let token = YnabApiToken::new("".to_string());
425        assert!(token.is_empty());
426        assert!(token.validate().is_err());
427    }
428
429    #[test]
430    fn test_token_validation() {
431        let valid_token = YnabApiToken::new("sk_1234567890abcdef".to_string());
432        assert!(valid_token.validate().is_ok());
433
434        let short_token = YnabApiToken::new("short".to_string());
435        assert!(short_token.validate().is_err());
436    }
437
438    #[test]
439    fn test_token_debug_format() {
440        let token = YnabApiToken::new("secret-token".to_string());
441        let debug_str = format!("{:?}", token);
442        assert!(debug_str.contains("[REDACTED]"));
443        assert!(!debug_str.contains("secret-token"));
444    }
445
446    #[test]
447    fn test_token_display_format() {
448        let token = YnabApiToken::new("secret-token".to_string());
449        let display_str = format!("{}", token);
450        assert_eq!(display_str, "[REDACTED TOKEN]");
451    }
452
453    #[test]
454    fn test_token_serialization() {
455        let token = YnabApiToken::new("secret-token".to_string());
456        let json = serde_json::to_string(&token).unwrap();
457        assert_eq!(json, "\"[REDACTED]\"");
458    }
459
460    #[test]
461    fn test_secrets_config_default() {
462        let config = SecretsConfig::default();
463        assert!(config.use_keyring);
464        assert!(config.auto_migrate);
465        assert!(!config.require_secure_storage);
466    }
467
468    #[test]
469    fn test_storage_method_display() {
470        assert_eq!(format!("{}", StorageMethod::Keyring), "System Keyring");
471        assert_eq!(
472            format!("{}", StorageMethod::EnvironmentVariable),
473            "Environment Variables"
474        );
475    }
476
477    #[test]
478    fn test_is_test_token() {
479        // Test various test patterns
480        assert!(YnabApiToken::is_test_token("test-token"));
481        assert!(YnabApiToken::is_test_token("dummy_token"));
482        assert!(YnabApiToken::is_test_token("PLACEHOLDER"));
483        assert!(YnabApiToken::is_test_token("ExampleToken"));
484        assert!(YnabApiToken::is_test_token("fake-key"));
485        assert!(YnabApiToken::is_test_token("demo123"));
486        assert!(YnabApiToken::is_test_token("sample_api_key"));
487        assert!(YnabApiToken::is_test_token("mock-token"));
488        assert!(YnabApiToken::is_test_token("ynab_api_token"));
489        assert!(YnabApiToken::is_test_token("your_token_here"));
490        assert!(YnabApiToken::is_test_token("xxxxx"));
491
492        // Test repeated characters
493        assert!(YnabApiToken::is_test_token("aaaaaaa"));
494        assert!(YnabApiToken::is_test_token("1111111"));
495        assert!(YnabApiToken::is_test_token("00000"));
496
497        // Test sequential patterns
498        assert!(YnabApiToken::is_test_token("abcdefghijklmnopqrstuvwxyz"));
499        assert!(YnabApiToken::is_test_token("123456789"));
500
501        // Test valid-looking tokens (should not be test tokens)
502        assert!(!YnabApiToken::is_test_token("sk_1a2b3c4d5e6f7890abcdef"));
503        assert!(!YnabApiToken::is_test_token(
504            "real_api_key_with_actual_randomness"
505        ));
506        assert!(!YnabApiToken::is_test_token("abc123xyz789"));
507    }
508
509    #[test]
510    fn test_test_token_validation_blocked() {
511        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "true");
512
513        let test_token = YnabApiToken::new("test-token".to_string());
514        let result = test_token.validate();
515        assert!(result.is_err());
516        assert!(result
517            .unwrap_err()
518            .to_string()
519            .contains("Test/placeholder token detected"));
520
521        std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
522    }
523
524    #[test]
525    fn test_test_token_validation_allowed() {
526        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "false");
527
528        let test_token = YnabApiToken::new("test-token-long-enough".to_string());
529        let result = test_token.validate();
530        assert!(result.is_ok()); // Should be allowed but warn
531
532        std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
533    }
534
535    #[test]
536    fn test_test_token_validation_no_env() {
537        std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
538
539        let test_token = YnabApiToken::new("test-token-long-enough".to_string());
540        let result = test_token.validate();
541        assert!(result.is_ok()); // Should be allowed with warning
542    }
543
544    #[test]
545    fn test_secrets_config_custom() {
546        let config = SecretsConfig {
547            use_keyring: false,
548            auto_migrate: false,
549            require_secure_storage: true,
550        };
551
552        assert!(!config.use_keyring);
553        assert!(!config.auto_migrate);
554        assert!(config.require_secure_storage);
555    }
556
557    #[test]
558    fn test_get_keyring_info() {
559        let info = SecretsManager::get_keyring_info();
560        assert!(!info.is_empty());
561
562        // Test platform-specific behavior
563        if cfg!(target_os = "macos") {
564            assert!(info.contains("macOS Keychain"));
565        } else if cfg!(target_os = "windows") {
566            assert!(info.contains("Windows Credential Manager"));
567        } else if cfg!(target_os = "linux") {
568            assert!(info.contains("Linux Kernel Keyring"));
569        } else {
570            assert!(info.contains("Generic keyring backend"));
571        }
572    }
573
574    #[test]
575    fn test_secrets_manager_creation_with_keyring_disabled() {
576        let config = SecretsConfig {
577            use_keyring: false,
578            auto_migrate: false,
579            require_secure_storage: false,
580        };
581
582        let manager = SecretsManager::new(config);
583        assert!(manager.is_ok());
584        let manager = manager.unwrap();
585        assert!(!manager.is_keyring_available());
586        assert_eq!(
587            manager.get_storage_method(),
588            StorageMethod::EnvironmentVariable
589        );
590    }
591
592    #[test]
593    fn test_secrets_manager_creation_with_required_secure_storage() {
594        let config = SecretsConfig {
595            use_keyring: true,
596            auto_migrate: false,
597            require_secure_storage: true,
598        };
599
600        // This may succeed or fail depending on system keyring availability
601        let manager_result = SecretsManager::new(config);
602        // Don't assert success/failure as it's system dependent
603        let _ = manager_result;
604    }
605
606    #[test]
607    fn test_token_deserialization_blocked() {
608        // Test that deserialization is blocked
609        let json = "\"some-token\"";
610        let result: std::result::Result<YnabApiToken, _> = serde_json::from_str(json);
611        assert!(result.is_err());
612        assert!(result
613            .unwrap_err()
614            .to_string()
615            .contains("cannot be deserialized"));
616    }
617
618    #[test]
619    fn test_token_short_validation_error() {
620        let short_token = YnabApiToken::new("short".to_string());
621        let result = short_token.validate();
622        assert!(result.is_err());
623        let error_msg = result.unwrap_err().to_string();
624        assert!(error_msg.contains("too short"));
625        assert!(error_msg.contains("5 characters"));
626    }
627
628    #[test]
629    fn test_token_empty_validation_error() {
630        let empty_token = YnabApiToken::new("".to_string());
631        let result = empty_token.validate();
632        assert!(result.is_err());
633        let error_msg = result.unwrap_err().to_string();
634        assert!(error_msg.contains("missing or empty"));
635    }
636
637    #[test]
638    fn test_storage_method_equality() {
639        assert_eq!(StorageMethod::Keyring, StorageMethod::Keyring);
640        assert_eq!(
641            StorageMethod::EnvironmentVariable,
642            StorageMethod::EnvironmentVariable
643        );
644        assert_ne!(StorageMethod::Keyring, StorageMethod::EnvironmentVariable);
645    }
646
647    #[test]
648    fn test_secrets_config_debug() {
649        let config = SecretsConfig::default();
650        let debug_str = format!("{:?}", config);
651        assert!(debug_str.contains("SecretsConfig"));
652        assert!(debug_str.contains("use_keyring"));
653    }
654
655    #[test]
656    fn test_token_clone() {
657        let token = YnabApiToken::new("test-token".to_string());
658        let cloned = token.clone();
659        assert_eq!(token.expose_secret(), cloned.expose_secret());
660    }
661
662    #[test]
663    fn test_repeated_char_detection_edge_cases() {
664        // Test short strings that shouldn't trigger repeated char detection
665        assert!(!YnabApiToken::is_test_token("aa"));
666        assert!(!YnabApiToken::is_test_token("aaa"));
667        assert!(!YnabApiToken::is_test_token("aaaa"));
668
669        // Test exactly 5 chars (should trigger)
670        assert!(YnabApiToken::is_test_token("aaaaa"));
671
672        // Test empty string
673        assert!(!YnabApiToken::is_test_token(""));
674    }
675
676    #[test]
677    fn test_env_var_parsing_edge_cases() {
678        // Test different env var values for blocking
679        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "TRUE");
680        let token = YnabApiToken::new("test-token-long".to_string());
681        assert!(token.validate().is_err());
682
683        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "1");
684        assert!(token.validate().is_err());
685
686        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "false");
687        assert!(token.validate().is_ok());
688
689        std::env::set_var("YNAB_BLOCK_TEST_TOKENS", "random_value");
690        assert!(token.validate().is_ok());
691
692        std::env::remove_var("YNAB_BLOCK_TEST_TOKENS");
693    }
694}