Skip to main content

rustyclaw_core/secrets/
mod.rs

1//! Secrets manager backed by an encrypted SecureStore vault.
2//!
3//! The vault is stored at `{credentials_dir}/secrets.json`.  Encryption uses
4//! either a CSPRNG-generated key file (`{credentials_dir}/secrets.key`) or a
5//! user-supplied password — never both.
6//!
7//! ## Storage layout
8//!
9//! | Key pattern            | Content                                          |
10//! |------------------------|--------------------------------------------------|
11//! | `cred:<name>`          | JSON-serialized [`SecretEntry`] metadata          |
12//! | `val:<name>`           | Primary secret value (or private key PEM / note)  |
13//! | `val:<name>:user`      | Username (for `UsernamePassword` kind)             |
14//! | `val:<name>:pub`       | Public key string (for `SshKey` kind)              |
15//! | `val:<name>:fields`    | JSON map of form-field key/value pairs             |
16//! | `val:<name>:card`      | JSON `{cardholder,number,expiry,cvv}`              |
17//! | `val:<name>:card_extra`| JSON map of additional payment card fields         |
18//! | `<bare key>`           | Legacy / raw secrets (API keys, TOTP, etc.)        |
19
20mod types;
21mod vault;
22
23use std::path::PathBuf;
24
25pub use types::{
26    AccessContext, AccessPolicy, BrowserStore, Cookie, CredentialValue, Secret, SecretEntry,
27    SecretKind, WebStorage,
28};
29
30/// Secrets manager backed by an encrypted SecureStore vault.
31pub struct SecretsManager {
32    /// Path to the vault JSON file
33    pub(crate) vault_path: PathBuf,
34    /// Path to the key file (only used when no password is set)
35    pub(crate) key_path: PathBuf,
36    /// Optional user-supplied password (used instead of the key file)
37    pub(crate) password: Option<String>,
38    /// In-memory vault handle (loaded lazily)
39    pub(crate) vault: Option<securestore::SecretsManager>,
40    /// Whether the agent can access secrets without prompting
41    pub(crate) agent_access_enabled: bool,
42}
43
44impl SecretsManager {
45    /// Create a new `SecretsManager` rooted in `credentials_dir`.
46    ///
47    /// The vault and key files are created on-demand the first time a
48    /// mutating operation is performed.
49    pub fn new(credentials_dir: impl Into<PathBuf>) -> Self {
50        let dir: PathBuf = credentials_dir.into();
51        Self {
52            vault_path: dir.join("secrets.json"),
53            key_path: dir.join("secrets.key"),
54            password: None,
55            vault: None,
56            agent_access_enabled: false,
57        }
58    }
59
60    /// Create a `SecretsManager` that uses a password for encryption
61    /// instead of a key file.
62    pub fn with_password(credentials_dir: impl Into<PathBuf>, password: String) -> Self {
63        let dir: PathBuf = credentials_dir.into();
64        Self {
65            vault_path: dir.join("secrets.json"),
66            key_path: dir.join("secrets.key"),
67            password: Some(password),
68            vault: None,
69            agent_access_enabled: false,
70        }
71    }
72
73    /// Set the password after construction (e.g. after prompting the user).
74    ///
75    /// **Note:** This only affects how the vault is opened on next access.
76    /// If the vault already exists on disk with a different key source, you
77    /// must call [`change_password`](Self::change_password) instead.
78    pub fn set_password(&mut self, password: String) {
79        self.password = Some(password);
80        // Invalidate any previously loaded vault so it reloads with the
81        // new key source.
82        self.vault = None;
83    }
84
85    /// Remove the password and invalidate the loaded vault, returning the
86    /// manager to a locked state.
87    pub fn clear_password(&mut self) {
88        self.password = None;
89        self.vault = None;
90    }
91
92    /// Create a `SecretsManager` in a locked state.
93    ///
94    /// The vault file path is known but no password or key file has been
95    /// provided yet.  The vault cannot be accessed until
96    /// [`set_password`](Self::set_password) is called.
97    pub fn locked(credentials_dir: impl Into<PathBuf>) -> Self {
98        let dir: PathBuf = credentials_dir.into();
99        Self {
100            vault_path: dir.join("secrets.json"),
101            key_path: dir.join("secrets.key"),
102            password: None,
103            vault: None,
104            agent_access_enabled: false,
105        }
106    }
107
108    /// Check whether the vault is in a locked state (password-protected
109    /// vault with no password provided yet).
110    ///
111    /// Returns `true` if the vault file exists on disk, no key file is
112    /// present, and no password has been set — meaning the vault cannot
113    /// be decrypted without a password.
114    pub fn is_locked(&self) -> bool {
115        self.vault.is_none()
116            && self.password.is_none()
117            && !self.key_path.exists()
118            && self.vault_path.exists()
119    }
120
121    /// Return the current password, if one has been set.
122    ///
123    /// Used by the TUI to forward the vault password to the gateway
124    /// daemon so it can open the vault without prompting.
125    pub fn password(&self) -> Option<&str> {
126        self.password.as_deref()
127    }
128
129    // ── Access control ──────────────────────────────────────────────
130
131    /// Enable or disable automatic agent access to secrets
132    pub fn set_agent_access(&mut self, enabled: bool) {
133        self.agent_access_enabled = enabled;
134    }
135
136    /// Check if agent has access to secrets
137    pub fn has_agent_access(&self) -> bool {
138        self.agent_access_enabled
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::path::Path;
146    use std::sync::atomic::{AtomicU32, Ordering};
147    use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
148
149    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
150
151    fn temp_dir() -> PathBuf {
152        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
153        let dir =
154            std::env::temp_dir().join(format!("rustyclaw_test_{}_{}", std::process::id(), id));
155        let _ = std::fs::remove_dir_all(&dir);
156        std::fs::create_dir_all(&dir).unwrap();
157        dir
158    }
159
160    #[test]
161    fn test_secrets_manager_creation() {
162        let dir = temp_dir();
163        let manager = SecretsManager::new(&dir);
164        assert!(!manager.has_agent_access());
165
166        // Vault files should not exist yet (lazy creation)
167        assert!(!dir.join("secrets.json").exists());
168        let _ = std::fs::remove_dir_all(&dir);
169    }
170
171    #[test]
172    fn test_agent_access_control() {
173        let dir = temp_dir();
174        let mut manager = SecretsManager::new(&dir);
175        assert!(!manager.has_agent_access());
176
177        manager.set_agent_access(true);
178        assert!(manager.has_agent_access());
179
180        manager.set_agent_access(false);
181        assert!(!manager.has_agent_access());
182        let _ = std::fs::remove_dir_all(&dir);
183    }
184
185    #[test]
186    fn test_store_and_retrieve() {
187        let dir = temp_dir();
188        let mut manager = SecretsManager::new(&dir);
189        manager.set_agent_access(true);
190
191        manager.store_secret("api_key", "hunter2").unwrap();
192        assert!(Path::new(&dir.join("secrets.json")).exists());
193        assert!(Path::new(&dir.join("secrets.key")).exists());
194
195        let val = manager.get_secret("api_key", false).unwrap();
196        assert_eq!(val, Some("hunter2".to_string()));
197
198        // Non-existent key
199        let missing = manager.get_secret("nope", true).unwrap();
200        assert_eq!(missing, None);
201        let _ = std::fs::remove_dir_all(&dir);
202    }
203
204    #[test]
205    fn test_list_and_delete() {
206        let dir = temp_dir();
207        let mut manager = SecretsManager::new(&dir);
208        manager.set_agent_access(true);
209
210        manager.store_secret("a", "1").unwrap();
211        manager.store_secret("b", "2").unwrap();
212
213        let mut keys = manager.list_secrets();
214        keys.sort();
215        assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
216
217        manager.delete_secret("a").unwrap();
218        let keys = manager.list_secrets();
219        assert_eq!(keys, vec!["b".to_string()]);
220        let _ = std::fs::remove_dir_all(&dir);
221    }
222
223    #[test]
224    fn test_access_denied_without_approval() {
225        let dir = temp_dir();
226        let mut manager = SecretsManager::new(&dir);
227        manager.store_secret("secret", "value").unwrap();
228
229        // Agent access off + no user approval → None
230        let val = manager.get_secret("secret", false).unwrap();
231        assert_eq!(val, None);
232
233        // With user approval → Some
234        let val = manager.get_secret("secret", true).unwrap();
235        assert_eq!(val, Some("value".to_string()));
236        let _ = std::fs::remove_dir_all(&dir);
237    }
238
239    #[test]
240    fn test_reload_from_disk() {
241        let dir = temp_dir();
242
243        // Create and populate
244        {
245            let mut m = SecretsManager::new(&dir);
246            m.store_secret("persist", "yes").unwrap();
247        }
248
249        // Load fresh and read back
250        {
251            let mut m = SecretsManager::new(&dir);
252            m.set_agent_access(true);
253            let val = m.get_secret("persist", false).unwrap();
254            assert_eq!(val, Some("yes".to_string()));
255        }
256        let _ = std::fs::remove_dir_all(&dir);
257    }
258
259    #[test]
260    fn test_password_based_vault() {
261        let dir = temp_dir();
262
263        // Create a password-protected vault and store a secret.
264        {
265            let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
266            m.store_secret("token", "abc123").unwrap();
267        }
268
269        // Vault file should exist, but key file should NOT.
270        assert!(dir.join("secrets.json").exists());
271        assert!(!dir.join("secrets.key").exists());
272
273        // Reload with the correct password.
274        {
275            let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
276            m.set_agent_access(true);
277            let val = m.get_secret("token", false).unwrap();
278            assert_eq!(val, Some("abc123".to_string()));
279        }
280
281        // Wrong password should fail to load.
282        {
283            let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
284            assert!(m.get_secret("token", true).is_err());
285        }
286
287        let _ = std::fs::remove_dir_all(&dir);
288    }
289
290    #[test]
291    fn test_change_password() {
292        let dir = temp_dir();
293
294        // Create a key-file vault and store some secrets.
295        {
296            let mut m = SecretsManager::new(&dir);
297            m.store_secret("api_key", "sk-abc").unwrap();
298            m.store_secret("token", "tok-xyz").unwrap();
299        }
300        assert!(dir.join("secrets.json").exists());
301        assert!(dir.join("secrets.key").exists());
302
303        // Re-open with key-file and change to a password.
304        {
305            let mut m = SecretsManager::new(&dir);
306            m.change_password("newpass".to_string()).unwrap();
307        }
308
309        // Key file should be removed after password migration.
310        assert!(!dir.join("secrets.key").exists());
311
312        // Reload with the new password — secrets should still be there.
313        {
314            let mut m = SecretsManager::with_password(&dir, "newpass".to_string());
315            m.set_agent_access(true);
316            assert_eq!(
317                m.get_secret("api_key", false).unwrap(),
318                Some("sk-abc".to_string())
319            );
320            assert_eq!(
321                m.get_secret("token", false).unwrap(),
322                Some("tok-xyz".to_string())
323            );
324        }
325
326        // Old key file should no longer work (it's deleted).
327        // Wrong password should fail.
328        {
329            let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
330            assert!(m.get_secret("api_key", true).is_err());
331        }
332
333        let _ = std::fs::remove_dir_all(&dir);
334    }
335
336    #[test]
337    fn test_change_password_between_passwords() {
338        let dir = temp_dir();
339
340        // Create a password-protected vault.
341        {
342            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
343            m.store_secret("secret", "value123").unwrap();
344        }
345
346        // Change the password.
347        {
348            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
349            m.change_password("new_pw".to_string()).unwrap();
350        }
351
352        // New password should work.
353        {
354            let mut m = SecretsManager::with_password(&dir, "new_pw".to_string());
355            m.set_agent_access(true);
356            assert_eq!(
357                m.get_secret("secret", false).unwrap(),
358                Some("value123".to_string())
359            );
360        }
361
362        // Old password should fail.
363        {
364            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
365            assert!(m.get_secret("secret", true).is_err());
366        }
367
368        let _ = std::fs::remove_dir_all(&dir);
369    }
370
371    #[test]
372    fn test_totp_setup_and_verify() {
373        let dir = temp_dir();
374        let mut manager = SecretsManager::new(&dir);
375        manager.set_agent_access(true);
376
377        // No TOTP secret initially.
378        assert!(!manager.has_totp());
379
380        // Set up TOTP and get the otpauth:// URL.
381        let url = manager.setup_totp("testuser").unwrap();
382        assert!(url.starts_with("otpauth://totp/"));
383        assert!(url.contains("RustyClaw"));
384        assert!(manager.has_totp());
385
386        // Generate a valid code from the stored secret and verify it.
387        let encoded = manager
388            .get_secret(SecretsManager::TOTP_SECRET_KEY, true)
389            .unwrap()
390            .unwrap();
391        let secret = TotpSecret::Encoded(encoded);
392        let secret_bytes = secret.to_bytes().unwrap();
393        let totp = TOTP::new(
394            Algorithm::SHA1,
395            6,
396            1,
397            30,
398            secret_bytes,
399            Some("RustyClaw".to_string()),
400            "testuser".to_string(),
401        )
402        .unwrap();
403        let now = std::time::SystemTime::now()
404            .duration_since(std::time::UNIX_EPOCH)
405            .unwrap()
406            .as_secs();
407        let code = totp.generate(now);
408
409        assert!(manager.verify_totp(&code).unwrap());
410
411        // Wrong code should fail.
412        assert!(!manager.verify_totp("000000").unwrap());
413
414        // Remove TOTP.
415        manager.remove_totp().unwrap();
416        assert!(!manager.has_totp());
417
418        let _ = std::fs::remove_dir_all(&dir);
419    }
420
421    // ── Typed credential tests ──────────────────────────────────────
422
423    #[test]
424    fn test_store_and_retrieve_api_key() {
425        let dir = temp_dir();
426        let mut m = SecretsManager::new(&dir);
427
428        let entry = SecretEntry {
429            label: "Anthropic".to_string(),
430            kind: SecretKind::ApiKey,
431            policy: AccessPolicy::WithApproval,
432            description: None,
433            disabled: false,
434        };
435        m.store_credential("anthropic_key", &entry, "sk-ant-12345", None)
436            .unwrap();
437
438        let ctx = AccessContext {
439            user_approved: true,
440            ..Default::default()
441        };
442        let (meta, val) = m.get_credential("anthropic_key", &ctx).unwrap().unwrap();
443        assert_eq!(meta.kind, SecretKind::ApiKey);
444        assert_eq!(meta.label, "Anthropic");
445        match val {
446            CredentialValue::Single(v) => assert_eq!(v, "sk-ant-12345"),
447            _ => panic!("Expected Single"),
448        }
449        let _ = std::fs::remove_dir_all(&dir);
450    }
451
452    #[test]
453    fn test_store_and_retrieve_username_password() {
454        let dir = temp_dir();
455        let mut m = SecretsManager::new(&dir);
456
457        let entry = SecretEntry {
458            label: "Registry".to_string(),
459            kind: SecretKind::UsernamePassword,
460            policy: AccessPolicy::Always,
461            description: None,
462            disabled: false,
463        };
464        m.store_credential("registry", &entry, "s3cret", Some("admin"))
465            .unwrap();
466
467        let ctx = AccessContext::default();
468        let (_, val) = m.get_credential("registry", &ctx).unwrap().unwrap();
469        match val {
470            CredentialValue::UserPass { username, password } => {
471                assert_eq!(username, "admin");
472                assert_eq!(password, "s3cret");
473            }
474            _ => panic!("Expected UserPass"),
475        }
476        let _ = std::fs::remove_dir_all(&dir);
477    }
478
479    #[test]
480    fn test_store_http_passkey() {
481        let dir = temp_dir();
482        let mut m = SecretsManager::new(&dir);
483
484        let entry = SecretEntry {
485            label: "WebAuthn passkey".to_string(),
486            kind: SecretKind::HttpPasskey,
487            policy: AccessPolicy::WithAuth,
488            description: Some("FIDO2 credential".to_string()),
489            disabled: false,
490        };
491        m.store_credential("passkey1", &entry, "cred-id-base64", None)
492            .unwrap();
493
494        // Access without authentication should be denied.
495        let ctx = AccessContext {
496            user_approved: true,
497            ..Default::default()
498        };
499        assert!(m.get_credential("passkey1", &ctx).is_err());
500
501        // Access with authentication should succeed.
502        let ctx = AccessContext {
503            authenticated: true,
504            ..Default::default()
505        };
506        let (meta, val) = m.get_credential("passkey1", &ctx).unwrap().unwrap();
507        assert_eq!(meta.kind, SecretKind::HttpPasskey);
508        match val {
509            CredentialValue::Single(v) => assert_eq!(v, "cred-id-base64"),
510            _ => panic!("Expected Single"),
511        }
512        let _ = std::fs::remove_dir_all(&dir);
513    }
514
515    #[test]
516    fn test_generate_ssh_key() {
517        let dir = temp_dir();
518        let mut m = SecretsManager::new(&dir);
519
520        let pubkey = m
521            .generate_ssh_key(
522                "rustyclaw_agent",
523                "rustyclaw@agent",
524                AccessPolicy::WithApproval,
525            )
526            .unwrap();
527
528        assert!(pubkey.starts_with("ssh-ed25519 "));
529        assert!(pubkey.contains("rustyclaw@agent"));
530
531        // Retrieve via typed API.
532        let ctx = AccessContext {
533            user_approved: true,
534            ..Default::default()
535        };
536        let (meta, val) = m.get_credential("rustyclaw_agent", &ctx).unwrap().unwrap();
537        assert_eq!(meta.kind, SecretKind::SshKey);
538        match val {
539            CredentialValue::SshKeyPair {
540                private_key,
541                public_key,
542            } => {
543                assert!(private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
544                assert!(public_key.starts_with("ssh-ed25519 "));
545            }
546            _ => panic!("Expected SshKeyPair"),
547        }
548
549        // Delete should clean up vault entries.
550        m.delete_credential("rustyclaw_agent").unwrap();
551
552        let _ = std::fs::remove_dir_all(&dir);
553    }
554
555    #[test]
556    fn test_list_credentials() {
557        let dir = temp_dir();
558        let mut m = SecretsManager::new(&dir);
559
560        let e1 = SecretEntry {
561            label: "Key A".to_string(),
562            kind: SecretKind::ApiKey,
563            policy: AccessPolicy::Always,
564            description: None,
565            disabled: false,
566        };
567        let e2 = SecretEntry {
568            label: "Key B".to_string(),
569            kind: SecretKind::Token,
570            policy: AccessPolicy::WithApproval,
571            description: None,
572            disabled: false,
573        };
574        m.store_credential("a", &e1, "val_a", None).unwrap();
575        m.store_credential("b", &e2, "val_b", None).unwrap();
576
577        // Also store a raw legacy secret — should NOT appear in list_credentials.
578        m.store_secret("legacy_key", "legacy_val").unwrap();
579
580        let creds = m.list_credentials();
581        let names: Vec<&str> = creds.iter().map(|(n, _)| n.as_str()).collect();
582        assert!(names.contains(&"a"));
583        assert!(names.contains(&"b"));
584        assert!(!names.contains(&"legacy_key"));
585        assert_eq!(creds.len(), 2);
586
587        let _ = std::fs::remove_dir_all(&dir);
588    }
589
590    #[test]
591    fn test_list_all_entries_includes_legacy_keys() {
592        let dir = temp_dir();
593        let mut m = SecretsManager::new(&dir);
594
595        // Store a typed credential.
596        let entry = SecretEntry {
597            label: "Typed".to_string(),
598            kind: SecretKind::ApiKey,
599            policy: AccessPolicy::Always,
600            description: None,
601            disabled: false,
602        };
603        m.store_credential("typed_one", &entry, "val", None)
604            .unwrap();
605
606        // Store legacy bare-key secrets (one known provider, one unknown).
607        m.store_secret("ANTHROPIC_API_KEY", "sk-ant-xxx").unwrap();
608        m.store_secret("MY_CUSTOM_SECRET", "custom-val").unwrap();
609
610        // Store internal keys that should NOT appear.
611        m.store_secret("__init", "").unwrap();
612
613        let all = m.list_all_entries();
614        let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
615
616        // Typed credential appears.
617        assert!(names.contains(&"typed_one"));
618        // Known provider legacy key appears with correct label.
619        assert!(names.contains(&"ANTHROPIC_API_KEY"));
620        let anth = all.iter().find(|(n, _)| n == "ANTHROPIC_API_KEY").unwrap();
621        assert_eq!(anth.1.kind, SecretKind::ApiKey);
622        assert!(anth.1.label.contains("Anthropic"));
623
624        // Unknown legacy key appears with humanised label.
625        assert!(names.contains(&"MY_CUSTOM_SECRET"));
626        let custom = all.iter().find(|(n, _)| n == "MY_CUSTOM_SECRET").unwrap();
627        assert_eq!(custom.1.kind, SecretKind::Other);
628
629        // Internal keys excluded.
630        assert!(!names.contains(&"__init"));
631
632        // Sub-keys (cred:*, val:*) excluded.
633        assert!(
634            !names
635                .iter()
636                .any(|n| n.starts_with("cred:") || n.starts_with("val:"))
637        );
638
639        let _ = std::fs::remove_dir_all(&dir);
640    }
641
642    // ── Access policy tests ─────────────────────────────────────────
643
644    #[test]
645    fn test_policy_always() {
646        let dir = temp_dir();
647        let mut m = SecretsManager::new(&dir);
648        let entry = SecretEntry {
649            label: "open".to_string(),
650            kind: SecretKind::Token,
651            policy: AccessPolicy::Always,
652            description: None,
653            disabled: false,
654        };
655        m.store_credential("open_tok", &entry, "val", None).unwrap();
656
657        // Should succeed with an empty context.
658        let ctx = AccessContext::default();
659        assert!(m.get_credential("open_tok", &ctx).unwrap().is_some());
660
661        let _ = std::fs::remove_dir_all(&dir);
662    }
663
664    #[test]
665    fn test_policy_with_approval_denied() {
666        let dir = temp_dir();
667        let mut m = SecretsManager::new(&dir);
668        let entry = SecretEntry {
669            label: "guarded".to_string(),
670            kind: SecretKind::ApiKey,
671            policy: AccessPolicy::WithApproval,
672            description: None,
673            disabled: false,
674        };
675        m.store_credential("guarded", &entry, "val", None).unwrap();
676
677        // No approval, no agent_access → denied.
678        let ctx = AccessContext::default();
679        assert!(m.get_credential("guarded", &ctx).is_err());
680
681        // With approval → ok.
682        let ctx = AccessContext {
683            user_approved: true,
684            ..Default::default()
685        };
686        assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
687
688        // With agent_access enabled → also ok.
689        m.set_agent_access(true);
690        let ctx = AccessContext::default();
691        assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
692
693        let _ = std::fs::remove_dir_all(&dir);
694    }
695
696    #[test]
697    fn test_policy_with_auth() {
698        let dir = temp_dir();
699        let mut m = SecretsManager::new(&dir);
700        let entry = SecretEntry {
701            label: "high-sec".to_string(),
702            kind: SecretKind::ApiKey,
703            policy: AccessPolicy::WithAuth,
704            description: None,
705            disabled: false,
706        };
707        m.store_credential("hs", &entry, "val", None).unwrap();
708
709        // Even with user_approved, needs authenticated.
710        let ctx = AccessContext {
711            user_approved: true,
712            ..Default::default()
713        };
714        assert!(m.get_credential("hs", &ctx).is_err());
715
716        let ctx = AccessContext {
717            authenticated: true,
718            ..Default::default()
719        };
720        assert!(m.get_credential("hs", &ctx).unwrap().is_some());
721
722        let _ = std::fs::remove_dir_all(&dir);
723    }
724
725    #[test]
726    fn test_policy_skill_only() {
727        let dir = temp_dir();
728        let mut m = SecretsManager::new(&dir);
729        let entry = SecretEntry {
730            label: "deploy-key".to_string(),
731            kind: SecretKind::Token,
732            policy: AccessPolicy::SkillOnly(vec!["deploy".to_string(), "ci".to_string()]),
733            description: None,
734            disabled: false,
735        };
736        m.store_credential("dk", &entry, "val", None).unwrap();
737
738        // No skill → denied.
739        let ctx = AccessContext {
740            user_approved: true,
741            ..Default::default()
742        };
743        assert!(m.get_credential("dk", &ctx).is_err());
744
745        // Wrong skill → denied.
746        let ctx = AccessContext {
747            active_skill: Some("build".to_string()),
748            ..Default::default()
749        };
750        assert!(m.get_credential("dk", &ctx).is_err());
751
752        // Correct skill → ok.
753        let ctx = AccessContext {
754            active_skill: Some("deploy".to_string()),
755            ..Default::default()
756        };
757        assert!(m.get_credential("dk", &ctx).unwrap().is_some());
758
759        let _ = std::fs::remove_dir_all(&dir);
760    }
761
762    #[test]
763    fn test_delete_credential() {
764        let dir = temp_dir();
765        let mut m = SecretsManager::new(&dir);
766        let entry = SecretEntry {
767            label: "tmp".to_string(),
768            kind: SecretKind::Token,
769            policy: AccessPolicy::Always,
770            description: None,
771            disabled: false,
772        };
773        m.store_credential("tmp", &entry, "val", None).unwrap();
774        assert_eq!(m.list_credentials().len(), 1);
775
776        m.delete_credential("tmp").unwrap();
777        assert_eq!(m.list_credentials().len(), 0);
778
779        // get_credential should return None now.
780        let ctx = AccessContext::default();
781        assert!(m.get_credential("tmp", &ctx).unwrap().is_none());
782
783        let _ = std::fs::remove_dir_all(&dir);
784    }
785
786    // ── Web-navigation credential tests ─────────────────────────────
787
788    #[test]
789    fn test_store_and_retrieve_form_autofill() {
790        let dir = temp_dir();
791        let mut m = SecretsManager::new(&dir);
792
793        let entry = SecretEntry {
794            label: "Shipping address".to_string(),
795            kind: SecretKind::FormAutofill,
796            policy: AccessPolicy::WithApproval,
797            description: Some("https://example.com/checkout".to_string()),
798            disabled: false,
799        };
800        let mut fields = std::collections::BTreeMap::new();
801        fields.insert("name".to_string(), "Ada Lovelace".to_string());
802        fields.insert("email".to_string(), "ada@example.com".to_string());
803        fields.insert("phone".to_string(), "+1-555-0100".to_string());
804        fields.insert("address".to_string(), "1 Infinite Loop".to_string());
805
806        m.store_form_autofill("shipping", &entry, &fields).unwrap();
807
808        let ctx = AccessContext {
809            user_approved: true,
810            ..Default::default()
811        };
812        let (meta, val) = m.get_credential("shipping", &ctx).unwrap().unwrap();
813        assert_eq!(meta.kind, SecretKind::FormAutofill);
814        assert_eq!(meta.label, "Shipping address");
815        match val {
816            CredentialValue::FormFields(f) => {
817                assert_eq!(f.len(), 4);
818                assert_eq!(f["name"], "Ada Lovelace");
819                assert_eq!(f["email"], "ada@example.com");
820            }
821            _ => panic!("Expected FormFields"),
822        }
823        let _ = std::fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn test_store_and_retrieve_payment_method() {
828        let dir = temp_dir();
829        let mut m = SecretsManager::new(&dir);
830
831        let entry = SecretEntry {
832            label: "Visa ending 4242".to_string(),
833            kind: SecretKind::PaymentMethod,
834            policy: AccessPolicy::WithAuth,
835            description: None,
836            disabled: false,
837        };
838        let mut extra = std::collections::BTreeMap::new();
839        extra.insert("billing_zip".to_string(), "94025".to_string());
840
841        m.store_payment_method(
842            "visa_4242",
843            &entry,
844            "A. Lovelace",
845            "4242424242424242",
846            "12/28",
847            "123",
848            &extra,
849        )
850        .unwrap();
851
852        // Needs authentication.
853        let ctx = AccessContext {
854            user_approved: true,
855            ..Default::default()
856        };
857        assert!(m.get_credential("visa_4242", &ctx).is_err());
858
859        let ctx = AccessContext {
860            authenticated: true,
861            ..Default::default()
862        };
863        let (meta, val) = m.get_credential("visa_4242", &ctx).unwrap().unwrap();
864        assert_eq!(meta.kind, SecretKind::PaymentMethod);
865        match val {
866            CredentialValue::PaymentCard {
867                cardholder,
868                number,
869                expiry,
870                cvv,
871                extra,
872            } => {
873                assert_eq!(cardholder, "A. Lovelace");
874                assert_eq!(number, "4242424242424242");
875                assert_eq!(expiry, "12/28");
876                assert_eq!(cvv, "123");
877                assert_eq!(extra["billing_zip"], "94025");
878            }
879            _ => panic!("Expected PaymentCard"),
880        }
881
882        // Delete should clean everything up.
883        m.delete_credential("visa_4242").unwrap();
884        assert_eq!(m.list_credentials().len(), 0);
885
886        let _ = std::fs::remove_dir_all(&dir);
887    }
888
889    #[test]
890    fn test_store_and_retrieve_secure_note() {
891        let dir = temp_dir();
892        let mut m = SecretsManager::new(&dir);
893
894        let entry = SecretEntry {
895            label: "Recovery codes".to_string(),
896            kind: SecretKind::SecureNote,
897            policy: AccessPolicy::WithAuth,
898            description: Some("GitHub 2FA backup codes".to_string()),
899            disabled: false,
900        };
901        let note = "abcde-12345\nfghij-67890\nklmno-13579";
902        m.store_credential("gh_recovery", &entry, note, None)
903            .unwrap();
904
905        let ctx = AccessContext {
906            authenticated: true,
907            ..Default::default()
908        };
909        let (meta, val) = m.get_credential("gh_recovery", &ctx).unwrap().unwrap();
910        assert_eq!(meta.kind, SecretKind::SecureNote);
911        assert_eq!(
912            meta.description,
913            Some("GitHub 2FA backup codes".to_string())
914        );
915        match val {
916            CredentialValue::Single(v) => assert_eq!(v, note),
917            _ => panic!("Expected Single"),
918        }
919        let _ = std::fs::remove_dir_all(&dir);
920    }
921
922    #[test]
923    fn test_form_autofill_delete_cleans_fields() {
924        let dir = temp_dir();
925        let mut m = SecretsManager::new(&dir);
926
927        let entry = SecretEntry {
928            label: "Login form".to_string(),
929            kind: SecretKind::FormAutofill,
930            policy: AccessPolicy::Always,
931            description: None,
932            disabled: false,
933        };
934        let mut fields = std::collections::BTreeMap::new();
935        fields.insert("user".to_string(), "alice".to_string());
936        m.store_form_autofill("login", &entry, &fields).unwrap();
937        assert_eq!(m.list_credentials().len(), 1);
938
939        m.delete_credential("login").unwrap();
940        assert_eq!(m.list_credentials().len(), 0);
941
942        // The :fields sub-key should also be gone.
943        m.set_agent_access(true);
944        let raw = m.get_secret("val:login:fields", false).unwrap();
945        assert_eq!(raw, None);
946
947        let _ = std::fs::remove_dir_all(&dir);
948    }
949
950    #[test]
951    fn test_disable_and_reenable_credential() {
952        let dir = temp_dir();
953        let mut m = SecretsManager::new(&dir);
954
955        let entry = SecretEntry {
956            label: "my key".to_string(),
957            kind: SecretKind::ApiKey,
958            policy: AccessPolicy::Always,
959            description: None,
960            disabled: false,
961        };
962        m.store_credential("k", &entry, "secret", None).unwrap();
963
964        // Initially accessible.
965        let ctx = AccessContext::default();
966        assert!(m.get_credential("k", &ctx).unwrap().is_some());
967
968        // Disable it — access should fail.
969        m.set_credential_disabled("k", true).unwrap();
970        assert!(m.get_credential("k", &ctx).is_err());
971
972        // Still listed.
973        let creds = m.list_credentials();
974        assert_eq!(creds.len(), 1);
975        assert!(creds[0].1.disabled);
976
977        // Re-enable — access should work again.
978        m.set_credential_disabled("k", false).unwrap();
979        assert!(m.get_credential("k", &ctx).unwrap().is_some());
980
981        let _ = std::fs::remove_dir_all(&dir);
982    }
983
984    #[test]
985    fn test_disable_legacy_key_promotes_to_typed() {
986        let dir = temp_dir();
987        let mut m = SecretsManager::new(&dir);
988
989        // Store a bare-key secret (no cred: metadata).
990        m.store_secret("MY_BARE_KEY", "bare_val").unwrap();
991
992        // Disabling it should create a cred: entry.
993        m.set_credential_disabled("MY_BARE_KEY", true).unwrap();
994
995        let all = m.list_all_entries();
996        let bare = all.iter().find(|(n, _)| n == "MY_BARE_KEY").unwrap();
997        assert!(bare.1.disabled);
998
999        let _ = std::fs::remove_dir_all(&dir);
1000    }
1001
1002    #[test]
1003    fn test_set_credential_policy() {
1004        let dir = temp_dir();
1005        let mut m = SecretsManager::new(&dir);
1006
1007        let entry = SecretEntry {
1008            label: "my key".to_string(),
1009            kind: SecretKind::ApiKey,
1010            policy: AccessPolicy::WithApproval,
1011            description: None,
1012            disabled: false,
1013        };
1014        m.store_credential("k", &entry, "secret", None).unwrap();
1015
1016        // Default policy is ASK (WithApproval).
1017        let creds = m.list_credentials();
1018        assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1019
1020        // Change to OPEN.
1021        m.set_credential_policy("k", AccessPolicy::Always).unwrap();
1022        let creds = m.list_credentials();
1023        assert_eq!(creds[0].1.policy, AccessPolicy::Always);
1024
1025        // Change to AUTH.
1026        m.set_credential_policy("k", AccessPolicy::WithAuth)
1027            .unwrap();
1028        let creds = m.list_credentials();
1029        assert_eq!(creds[0].1.policy, AccessPolicy::WithAuth);
1030
1031        // Change to SKILL.
1032        m.set_credential_policy("k", AccessPolicy::SkillOnly(vec!["web".to_string()]))
1033            .unwrap();
1034        let creds = m.list_credentials();
1035        assert_eq!(
1036            creds[0].1.policy,
1037            AccessPolicy::SkillOnly(vec!["web".to_string()])
1038        );
1039
1040        // Change back to ASK.
1041        m.set_credential_policy("k", AccessPolicy::WithApproval)
1042            .unwrap();
1043        let creds = m.list_credentials();
1044        assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1045
1046        let _ = std::fs::remove_dir_all(&dir);
1047    }
1048
1049    #[test]
1050    fn test_set_policy_legacy_key_promotes_to_typed() {
1051        let dir = temp_dir();
1052        let mut m = SecretsManager::new(&dir);
1053
1054        // Store a bare-key secret (no cred: metadata).
1055        m.store_secret("LEGACY_KEY", "legacy_val").unwrap();
1056
1057        // Setting policy should create a cred: entry.
1058        m.set_credential_policy("LEGACY_KEY", AccessPolicy::Always)
1059            .unwrap();
1060
1061        let all = m.list_all_entries();
1062        let entry = all.iter().find(|(n, _)| n == "LEGACY_KEY").unwrap();
1063        assert_eq!(entry.1.policy, AccessPolicy::Always);
1064
1065        let _ = std::fs::remove_dir_all(&dir);
1066    }
1067}