Skip to main content

rustyclaw_core/secrets/
vault.rs

1//! Encryption/decryption logic, file I/O for the vault, and TOTP functionality.
2
3use anyhow::{Context, Result};
4use securestore::KeySource;
5use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
6
7use super::types::{
8    AccessContext, AccessPolicy, CredentialValue, SecretEntry, SecretKind,
9};
10use super::SecretsManager;
11
12impl SecretsManager {
13    /// Ensure the vault is loaded (or created if it doesn't exist yet).
14    pub(super) fn ensure_vault(&mut self) -> Result<&mut securestore::SecretsManager> {
15        if self.vault.is_none() {
16            let vault = if self.vault_path.exists() {
17                // Existing vault — load with password or key file.
18                if let Some(ref pw) = self.password {
19                    securestore::SecretsManager::load(&self.vault_path, KeySource::Password(pw))
20                        .context("Failed to load secrets vault (wrong password?)")?
21                } else if self.key_path.exists() {
22                    securestore::SecretsManager::load(
23                        &self.vault_path,
24                        KeySource::from_file(&self.key_path),
25                    )
26                    .context("Failed to load secrets vault")?
27                } else {
28                    anyhow::bail!(
29                        "Secrets vault exists but no key file or password provided. \
30                         Run `rustyclaw onboard` to configure."
31                    );
32                }
33            } else {
34                // First run: create a brand-new vault.
35                if let Some(parent) = self.vault_path.parent() {
36                    std::fs::create_dir_all(parent)
37                        .context("Failed to create secrets directory")?;
38                }
39                if let Some(ref pw) = self.password {
40                    // Password-based vault — no key file needed.
41                    let sman = securestore::SecretsManager::new(KeySource::Password(pw))
42                        .context("Failed to create new secrets vault")?;
43                    sman.save_as(&self.vault_path)
44                        .context("Failed to save new secrets vault")?;
45                    securestore::SecretsManager::load(&self.vault_path, KeySource::Password(pw))
46                        .context("Failed to reload newly-created secrets vault")?
47                } else {
48                    // Key-file-based vault.
49                    let sman = securestore::SecretsManager::new(KeySource::Csprng)
50                        .context("Failed to create new secrets vault")?;
51                    sman.export_key(&self.key_path)
52                        .context("Failed to export secrets key")?;
53                    sman.save_as(&self.vault_path)
54                        .context("Failed to save new secrets vault")?;
55                    securestore::SecretsManager::load(
56                        &self.vault_path,
57                        KeySource::from_file(&self.key_path),
58                    )
59                    .context("Failed to reload newly-created secrets vault")?
60                }
61            };
62            self.vault = Some(vault);
63        }
64        // SAFETY: we just ensured `self.vault` is `Some`.
65        Ok(self.vault.as_mut().unwrap())
66    }
67
68    /// Re-encrypt an existing vault with a new password.
69    ///
70    /// Loads the vault with the current key source, reads every secret,
71    /// creates a brand-new vault encrypted with `new_password`, writes
72    /// back all the secrets, and saves.  On success the in-memory state
73    /// is updated to use the new password.
74    pub fn change_password(&mut self, new_password: String) -> Result<()> {
75        // 1. Make sure the vault is loaded with the *current* credentials.
76        let old_vault = self.ensure_vault()?;
77
78        // 2. Read out every key → value pair.
79        let keys: Vec<String> = old_vault.keys().map(|s| s.to_string()).collect();
80        let mut entries: Vec<(String, String)> = Vec::new();
81        for key in &keys {
82            match old_vault.get(key) {
83                Ok(value) => entries.push((key.clone(), value)),
84                // Skip entries we can't decrypt (shouldn't happen, but be safe).
85                Err(_) => {}
86            }
87        }
88
89        // 3. Drop the old vault and create a new one with the new password.
90        self.vault = None;
91
92        let new_vault =
93            securestore::SecretsManager::new(KeySource::Password(&new_password))
94                .context("Failed to create vault with new password")?;
95        new_vault
96            .save_as(&self.vault_path)
97            .context("Failed to save re-encrypted vault")?;
98
99        // 4. Reload so we can write to it.
100        let mut reloaded =
101            securestore::SecretsManager::load(&self.vault_path, KeySource::Password(&new_password))
102                .context("Failed to reload vault with new password")?;
103
104        // 5. Write all secrets back.
105        for (key, value) in entries {
106            reloaded.set(&key, value);
107        }
108        reloaded.save().context("Failed to save re-keyed vault")?;
109
110        // 6. Update in-memory state.
111        self.password = Some(new_password);
112        self.vault = Some(reloaded);
113
114        // 7. Remove the old key file if it exists — no longer needed.
115        if self.key_path.exists() {
116            let _ = std::fs::remove_file(&self.key_path);
117        }
118
119        Ok(())
120    }
121
122    // ── CRUD operations ─────────────────────────────────────────────
123
124    /// Store (or overwrite) a secret in the vault and persist to disk.
125    pub fn store_secret(&mut self, key: &str, value: &str) -> Result<()> {
126        let vault = self.ensure_vault()?;
127        vault.set(key, value);
128        vault.save().context("Failed to save secrets vault")?;
129        Ok(())
130    }
131
132    /// Retrieve a secret from the vault.
133    ///
134    /// Returns `None` if the secret does not exist **or** if agent
135    /// access is disabled and the caller has not provided explicit
136    /// user approval.
137    pub fn get_secret(&mut self, key: &str, user_approved: bool) -> Result<Option<String>> {
138        if !self.agent_access_enabled && !user_approved {
139            return Ok(None);
140        }
141
142        let vault = self.ensure_vault()?;
143        match vault.get(key) {
144            Ok(value) => Ok(Some(value)),
145            Err(e) if e.kind() == securestore::ErrorKind::SecretNotFound => Ok(None),
146            Err(e) => Err(anyhow::anyhow!("Failed to get secret: {}", e)),
147        }
148    }
149
150    /// Delete a secret from the vault and persist to disk.
151    pub fn delete_secret(&mut self, key: &str) -> Result<()> {
152        let vault = self.ensure_vault()?;
153        vault.remove(key).context("Failed to remove secret")?;
154        vault.save().context("Failed to save secrets vault")?;
155        Ok(())
156    }
157
158    /// List all stored secret keys (not values).
159    pub fn list_secrets(&mut self) -> Vec<String> {
160        match self.ensure_vault() {
161            Ok(vault) => vault.keys().map(|s| s.to_string()).collect(),
162            Err(_) => Vec::new(),
163        }
164    }
165
166    // ── Typed credential API ────────────────────────────────────────
167
168    /// Store a typed credential in the vault.
169    ///
170    /// For `UsernamePassword`, supply the password as `value` and the
171    /// username as `username`.  For all other kinds, `username` is
172    /// ignored and `value` holds the single secret string.
173    ///
174    /// For `SshKey`, prefer [`generate_ssh_key`] which creates the
175    /// keypair automatically.
176    pub fn store_credential(
177        &mut self,
178        name: &str,
179        entry: &SecretEntry,
180        value: &str,
181        username: Option<&str>,
182    ) -> Result<()> {
183        let meta_key = format!("cred:{}", name);
184        let val_key = format!("val:{}", name);
185
186        let meta_json =
187            serde_json::to_string(entry).context("Failed to serialize credential metadata")?;
188        self.store_secret(&meta_key, &meta_json)?;
189        self.store_secret(&val_key, value)?;
190
191        if entry.kind == SecretKind::UsernamePassword {
192            let user_key = format!("val:{}:user", name);
193            self.store_secret(&user_key, username.unwrap_or(""))?;
194        }
195
196        Ok(())
197    }
198
199    /// Store a form-autofill credential (arbitrary key/value fields).
200    ///
201    /// `fields` maps field names (e.g. "email", "phone", "address")
202    /// to their values.  The `description` on the entry is a good
203    /// place to record the site URL or form name.
204    pub fn store_form_autofill(
205        &mut self,
206        name: &str,
207        entry: &SecretEntry,
208        fields: &std::collections::BTreeMap<String, String>,
209    ) -> Result<()> {
210        debug_assert_eq!(entry.kind, SecretKind::FormAutofill);
211
212        let meta_key = format!("cred:{}", name);
213        let fields_key = format!("val:{}:fields", name);
214
215        let meta_json =
216            serde_json::to_string(entry).context("Failed to serialize credential metadata")?;
217        let fields_json =
218            serde_json::to_string(fields).context("Failed to serialize form fields")?;
219
220        self.store_secret(&meta_key, &meta_json)?;
221        self.store_secret(&fields_key, &fields_json)?;
222        Ok(())
223    }
224
225    /// Store a payment-method credential.
226    pub fn store_payment_method(
227        &mut self,
228        name: &str,
229        entry: &SecretEntry,
230        cardholder: &str,
231        number: &str,
232        expiry: &str,
233        cvv: &str,
234        extra: &std::collections::BTreeMap<String, String>,
235    ) -> Result<()> {
236        debug_assert_eq!(entry.kind, SecretKind::PaymentMethod);
237
238        let meta_key = format!("cred:{}", name);
239        let card_key = format!("val:{}:card", name);
240        let extra_key = format!("val:{}:card_extra", name);
241
242        let meta_json =
243            serde_json::to_string(entry).context("Failed to serialize credential metadata")?;
244
245        #[derive(serde::Serialize)]
246        struct Card<'a> {
247            cardholder: &'a str,
248            number: &'a str,
249            expiry: &'a str,
250            cvv: &'a str,
251        }
252        let card_json = serde_json::to_string(&Card {
253            cardholder,
254            number,
255            expiry,
256            cvv,
257        })
258        .context("Failed to serialize card details")?;
259
260        self.store_secret(&meta_key, &meta_json)?;
261        self.store_secret(&card_key, &card_json)?;
262
263        if !extra.is_empty() {
264            let extra_json =
265                serde_json::to_string(extra).context("Failed to serialize card extras")?;
266            self.store_secret(&extra_key, &extra_json)?;
267        }
268
269        Ok(())
270    }
271
272    /// Retrieve a typed credential from the vault.
273    ///
274    /// `context` drives the permission check:
275    /// - `user_approved`: the user has explicitly said "yes" for this
276    ///   access (satisfies `WithApproval`).
277    /// - `authenticated`: the caller has already re-verified the vault
278    ///   password / TOTP (satisfies `WithAuth`).
279    /// - `active_skill`: if the agent is currently executing a skill,
280    ///   pass its name here (satisfies `SkillOnly` when listed).
281    pub fn get_credential(
282        &mut self,
283        name: &str,
284        ctx: &AccessContext,
285    ) -> Result<Option<(SecretEntry, CredentialValue)>> {
286        let meta_key = format!("cred:{}", name);
287        let val_key = format!("val:{}", name);
288
289        // Load metadata.
290        let meta_json = match self.get_secret(&meta_key, true)? {
291            Some(j) => j,
292            None => return Ok(None),
293        };
294        let entry: SecretEntry =
295            serde_json::from_str(&meta_json).context("Corrupted credential metadata")?;
296
297        // ── Disabled check ─────────────────────────────────────────
298        if entry.disabled {
299            anyhow::bail!("Credential '{}' is disabled", name,);
300        }
301
302        // ── Policy check ───────────────────────────────────────────
303        if !self.check_access(&entry.policy, ctx) {
304            anyhow::bail!(
305                "Access denied for credential '{}' (policy: {:?})",
306                name,
307                entry.policy,
308            );
309        }
310
311        // ── Load value(s) ──────────────────────────────────────────
312        let value = match entry.kind {
313            SecretKind::UsernamePassword => {
314                let password = self.get_secret(&val_key, true)?.unwrap_or_default();
315                let user_key = format!("val:{}:user", name);
316                let username = self.get_secret(&user_key, true)?.unwrap_or_default();
317                CredentialValue::UserPass { username, password }
318            }
319            SecretKind::SshKey => {
320                let private_key = self.get_secret(&val_key, true)?.unwrap_or_default();
321                let pub_key = format!("val:{}:pub", name);
322                let public_key = self.get_secret(&pub_key, true)?.unwrap_or_default();
323                CredentialValue::SshKeyPair {
324                    private_key,
325                    public_key,
326                }
327            }
328            SecretKind::FormAutofill => {
329                let fields_key = format!("val:{}:fields", name);
330                let fields_json = self
331                    .get_secret(&fields_key, true)?
332                    .unwrap_or_else(|| "{}".to_string());
333                let fields: std::collections::BTreeMap<String, String> =
334                    serde_json::from_str(&fields_json).context("Corrupted form-autofill fields")?;
335                CredentialValue::FormFields(fields)
336            }
337            SecretKind::PaymentMethod => {
338                let card_key = format!("val:{}:card", name);
339                let extra_key = format!("val:{}:card_extra", name);
340
341                let card_json = self
342                    .get_secret(&card_key, true)?
343                    .unwrap_or_else(|| "{}".to_string());
344
345                #[derive(serde::Deserialize)]
346                struct Card {
347                    #[serde(default)]
348                    cardholder: String,
349                    #[serde(default)]
350                    number: String,
351                    #[serde(default)]
352                    expiry: String,
353                    #[serde(default)]
354                    cvv: String,
355                }
356                let card: Card =
357                    serde_json::from_str(&card_json).context("Corrupted payment card data")?;
358
359                let extra: std::collections::BTreeMap<String, String> =
360                    match self.get_secret(&extra_key, true)? {
361                        Some(j) => serde_json::from_str(&j).context("Corrupted card extras")?,
362                        None => std::collections::BTreeMap::new(),
363                    };
364
365                CredentialValue::PaymentCard {
366                    cardholder: card.cardholder,
367                    number: card.number,
368                    expiry: card.expiry,
369                    cvv: card.cvv,
370                    extra,
371                }
372            }
373            _ => {
374                let v = self.get_secret(&val_key, true)?.unwrap_or_default();
375                CredentialValue::Single(v)
376            }
377        };
378
379        Ok(Some((entry, value)))
380    }
381
382    /// List all typed credential names (not raw / legacy keys).
383    pub fn list_credentials(&mut self) -> Vec<(String, SecretEntry)> {
384        let keys = self.list_secrets();
385        let mut result = Vec::new();
386        for key in &keys {
387            if let Some(name) = key.strip_prefix("cred:") {
388                if let Ok(Some(json)) = self.get_secret(key, true) {
389                    if let Ok(entry) = serde_json::from_str::<SecretEntry>(&json) {
390                        result.push((name.to_string(), entry));
391                    }
392                }
393            }
394        }
395        result
396    }
397
398    /// List *all* credentials — both typed (`cred:*`) and legacy bare-key
399    /// secrets (e.g. `ANTHROPIC_API_KEY`).
400    ///
401    /// Legacy keys that match a known provider secret name get a
402    /// synthesised [`SecretEntry`] with `kind = ApiKey` or `Token`.
403    /// Internal keys (TOTP secret, `__init`, `cred:*`, `val:*`) are
404    /// excluded.
405    pub fn list_all_entries(&mut self) -> Vec<(String, SecretEntry)> {
406        let all_keys = self.list_secrets();
407
408        let mut result = Vec::new();
409        let mut typed_names: std::collections::HashSet<String> = std::collections::HashSet::new();
410
411        // 1. Typed credentials (cred:* prefix)
412        for key in &all_keys {
413            if let Some(name) = key.strip_prefix("cred:") {
414                if let Ok(Some(json)) = self.get_secret(key, true) {
415                    if let Ok(entry) = serde_json::from_str::<SecretEntry>(&json) {
416                        typed_names.insert(name.to_string());
417                        result.push((name.to_string(), entry));
418                    }
419                }
420            }
421        }
422
423        // 2. Legacy / bare keys — skip internal bookkeeping keys.
424        for key in &all_keys {
425            // Skip typed credential sub-keys and internal keys.
426            if key.starts_with("cred:")
427                || key.starts_with("val:")
428                || key == Self::TOTP_SECRET_KEY
429                || key == "__init"
430            {
431                continue;
432            }
433            // Skip if already covered by a typed credential.
434            if typed_names.contains(key.as_str()) {
435                continue;
436            }
437
438            // Try to match against a known provider secret key.
439            let (label, kind) = Self::label_for_legacy_key(key);
440            result.push((
441                key.clone(),
442                SecretEntry {
443                    label,
444                    kind,
445                    policy: AccessPolicy::WithApproval,
446                    description: None,
447                    disabled: false,
448                },
449            ));
450        }
451
452        result
453    }
454
455    /// Produce a human-readable label and [`SecretKind`] for a legacy
456    /// bare vault key.
457    pub(super) fn label_for_legacy_key(key: &str) -> (String, SecretKind) {
458        use crate::providers::PROVIDERS;
459        // Check known providers first.
460        for p in PROVIDERS {
461            if p.secret_key == Some(key) {
462                let kind = match p.auth_method {
463                    crate::providers::AuthMethod::DeviceFlow => SecretKind::Token,
464                    _ => SecretKind::ApiKey,
465                };
466                return (p.display.to_string(), kind);
467            }
468        }
469        // Fallback: humanise the key name.
470        let label = key
471            .replace('_', " ")
472            .to_lowercase()
473            .split(' ')
474            .map(|w| {
475                let mut c = w.chars();
476                match c.next() {
477                    Some(first) => {
478                        let upper: String = first.to_uppercase().collect();
479                        format!("{}{}", upper, c.as_str())
480                    }
481                    None => String::new(),
482                }
483            })
484            .collect::<Vec<_>>()
485            .join(" ");
486        (label, SecretKind::Other)
487    }
488
489    /// Retrieve a credential's value(s) as displayable `(label, value)` pairs
490    /// for the TUI secret viewer.
491    ///
492    /// This bypasses the disabled check and the access-policy check because
493    /// the *user* is physically present and explicitly asked to view the
494    /// secret.  For legacy bare-key secrets (no `cred:` metadata) the raw
495    /// value is returned directly.
496    pub fn peek_credential_display(&mut self, name: &str) -> Result<Vec<(String, String)>> {
497        let meta_key = format!("cred:{}", name);
498        let val_key = format!("val:{}", name);
499
500        // Check if this is a typed credential.
501        if let Some(json) = self.get_secret(&meta_key, true)? {
502            let entry: SecretEntry =
503                serde_json::from_str(&json).context("Corrupted credential metadata")?;
504
505            let pairs = match entry.kind {
506                SecretKind::UsernamePassword => {
507                    let password = self.get_secret(&val_key, true)?.unwrap_or_default();
508                    let user_key = format!("val:{}:user", name);
509                    let username = self.get_secret(&user_key, true)?.unwrap_or_default();
510                    vec![
511                        ("Username".to_string(), username),
512                        ("Password".to_string(), password),
513                    ]
514                }
515                SecretKind::SshKey => {
516                    let private_key = self.get_secret(&val_key, true)?.unwrap_or_default();
517                    let pub_key = format!("val:{}:pub", name);
518                    let public_key = self.get_secret(&pub_key, true)?.unwrap_or_default();
519                    vec![
520                        ("Public Key".to_string(), public_key),
521                        ("Private Key".to_string(), private_key),
522                    ]
523                }
524                SecretKind::FormAutofill => {
525                    let fields_key = format!("val:{}:fields", name);
526                    let fields_json = self
527                        .get_secret(&fields_key, true)?
528                        .unwrap_or_else(|| "{}".to_string());
529                    let fields: std::collections::BTreeMap<String, String> =
530                        serde_json::from_str(&fields_json).unwrap_or_default();
531                    fields.into_iter().collect()
532                }
533                SecretKind::PaymentMethod => {
534                    let card_key = format!("val:{}:card", name);
535                    let card_json = self
536                        .get_secret(&card_key, true)?
537                        .unwrap_or_else(|| "{}".to_string());
538
539                    #[derive(serde::Deserialize)]
540                    struct Card {
541                        #[serde(default)]
542                        cardholder: String,
543                        #[serde(default)]
544                        number: String,
545                        #[serde(default)]
546                        expiry: String,
547                        #[serde(default)]
548                        cvv: String,
549                    }
550                    let card: Card = serde_json::from_str(&card_json).unwrap_or(Card {
551                        cardholder: String::new(),
552                        number: String::new(),
553                        expiry: String::new(),
554                        cvv: String::new(),
555                    });
556
557                    let mut pairs = vec![
558                        ("Cardholder".to_string(), card.cardholder),
559                        ("Number".to_string(), card.number),
560                        ("Expiry".to_string(), card.expiry),
561                        ("CVV".to_string(), card.cvv),
562                    ];
563
564                    let extra_key = format!("val:{}:card_extra", name);
565                    if let Some(j) = self.get_secret(&extra_key, true)? {
566                        let extra: std::collections::BTreeMap<String, String> =
567                            serde_json::from_str(&j).unwrap_or_default();
568                        for (k, v) in extra {
569                            pairs.push((k, v));
570                        }
571                    }
572                    pairs
573                }
574                _ => {
575                    let v = self.get_secret(&val_key, true)?.unwrap_or_default();
576                    vec![("Value".to_string(), v)]
577                }
578            };
579            return Ok(pairs);
580        }
581
582        // Legacy bare-key secret — return the raw value.
583        match self.get_secret(name, true)? {
584            Some(v) => Ok(vec![("Value".to_string(), v)]),
585            None => anyhow::bail!("Secret '{}' not found", name),
586        }
587    }
588
589    /// Delete a typed credential and all its associated vault keys.
590    pub fn delete_credential(&mut self, name: &str) -> Result<()> {
591        // Every possible sub-key pattern — best-effort removal.
592        let sub_keys = [
593            format!("cred:{}", name),
594            format!("val:{}", name),
595            format!("val:{}:user", name),
596            format!("val:{}:pub", name),
597            format!("val:{}:fields", name),
598            format!("val:{}:card", name),
599            format!("val:{}:card_extra", name),
600        ];
601        for key in &sub_keys {
602            let _ = self.delete_secret(key);
603        }
604
605        Ok(())
606    }
607
608    /// Enable or disable a credential.
609    ///
610    /// For typed credentials (`cred:<name>` exists) the `disabled`
611    /// flag is updated in the metadata envelope.  For legacy bare-key
612    /// secrets a typed envelope is created in-place so the flag can
613    /// be persisted.
614    pub fn set_credential_disabled(&mut self, name: &str, disabled: bool) -> Result<()> {
615        let meta_key = format!("cred:{}", name);
616
617        let mut entry: SecretEntry = match self.get_secret(&meta_key, true)? {
618            Some(json) => {
619                serde_json::from_str(&json).context("Corrupted credential metadata")?
620            }
621            None => {
622                // Legacy bare key — promote to typed entry.
623                let (label, kind) = Self::label_for_legacy_key(name);
624                SecretEntry {
625                    label,
626                    kind,
627                    policy: AccessPolicy::WithApproval,
628                    description: None,
629                    disabled: false,
630                }
631            }
632        };
633
634        entry.disabled = disabled;
635
636        let meta_json =
637            serde_json::to_string(&entry).context("Failed to serialize credential metadata")?;
638        self.store_secret(&meta_key, &meta_json)?;
639        Ok(())
640    }
641
642    /// Change the access policy of a credential.
643    pub fn set_credential_policy(&mut self, name: &str, policy: AccessPolicy) -> Result<()> {
644        let meta_key = format!("cred:{}", name);
645
646        let mut entry: SecretEntry = match self.get_secret(&meta_key, true)? {
647            Some(json) => {
648                serde_json::from_str(&json).context("Corrupted credential metadata")?
649            }
650            None => {
651                // Legacy bare key — promote to typed entry.
652                let (label, kind) = Self::label_for_legacy_key(name);
653                SecretEntry {
654                    label,
655                    kind,
656                    policy: AccessPolicy::WithApproval,
657                    description: None,
658                    disabled: false,
659                }
660            }
661        };
662
663        entry.policy = policy;
664
665        let meta_json =
666            serde_json::to_string(&entry).context("Failed to serialize credential metadata")?;
667        self.store_secret(&meta_key, &meta_json)?;
668        Ok(())
669    }
670
671    // ── SSH key generation ──────────────────────────────────────────
672
673    /// Generate a new Ed25519 SSH keypair and store it in the vault
674    /// as an `SshKey` credential.
675    ///
676    /// Returns the public key string (`ssh-ed25519 AAAA… <comment>`).
677    pub fn generate_ssh_key(
678        &mut self,
679        name: &str,
680        comment: &str,
681        policy: AccessPolicy,
682    ) -> Result<String> {
683        use ssh_key::private::PrivateKey;
684
685        // Generate keypair.
686        let private = PrivateKey::random(&mut ssh_key::rand_core::OsRng, ssh_key::Algorithm::Ed25519)
687            .map_err(|e| anyhow::anyhow!("Failed to generate SSH key: {}", e))?;
688
689        let private_pem = private
690            .to_openssh(ssh_key::LineEnding::LF)
691            .map_err(|e| anyhow::anyhow!("Failed to encode private key: {}", e))?;
692
693        let public = private.public_key();
694        let public_openssh = public
695            .to_openssh()
696            .map_err(|e| anyhow::anyhow!("Failed to encode public key: {}", e))?;
697
698        let public_str = if comment.is_empty() {
699            public_openssh.to_string()
700        } else {
701            format!("{} {}", public_openssh, comment)
702        };
703
704        // Store in vault.
705        let entry = SecretEntry {
706            label: format!("SSH key ({})", name),
707            kind: SecretKind::SshKey,
708            policy,
709            description: Some(format!("Ed25519 keypair — {}", comment)),
710            disabled: false,
711        };
712
713        let meta_key = format!("cred:{}", name);
714        let val_key = format!("val:{}", name);
715        let pub_vault_key = format!("val:{}:pub", name);
716
717        let meta_json =
718            serde_json::to_string(&entry).context("Failed to serialize credential metadata")?;
719        self.store_secret(&meta_key, &meta_json)?;
720        self.store_secret(&val_key, private_pem.to_string().as_str())?;
721        self.store_secret(&pub_vault_key, &public_str)?;
722
723        Ok(public_str)
724    }
725
726    // ── Access policy enforcement ───────────────────────────────────
727
728    /// Evaluate whether the given [`AccessContext`] satisfies a
729    /// credential's [`AccessPolicy`].
730    pub(super) fn check_access(&self, policy: &AccessPolicy, ctx: &AccessContext) -> bool {
731        match policy {
732            AccessPolicy::Always => true,
733            AccessPolicy::WithApproval => ctx.user_approved || self.agent_access_enabled,
734            AccessPolicy::WithAuth => ctx.authenticated,
735            AccessPolicy::SkillOnly(allowed) => {
736                if let Some(ref skill) = ctx.active_skill {
737                    allowed.iter().any(|s| s == skill)
738                } else {
739                    false
740                }
741            }
742        }
743    }
744
745    // ── TOTP two-factor authentication ──────────────────────────────
746
747    /// The vault key used to store the TOTP shared secret.
748    pub(super) const TOTP_SECRET_KEY: &'static str = "__rustyclaw_totp_secret";
749
750    /// Generate a fresh TOTP secret, store it in the vault, and return
751    /// the `otpauth://` URI (suitable for QR codes / manual entry in an
752    /// authenticator app).
753    pub fn setup_totp(&mut self, account_name: &str) -> Result<String> {
754        self.setup_totp_with_issuer(account_name, "RustyClaw")
755    }
756
757    /// Like [`setup_totp`](Self::setup_totp) but with a custom issuer name
758    /// (shown as the app/service label in authenticator apps).
759    pub fn setup_totp_with_issuer(&mut self, account_name: &str, issuer: &str) -> Result<String> {
760        let secret = TotpSecret::generate_secret();
761        let secret_bytes = secret
762            .to_bytes()
763            .map_err(|e| anyhow::anyhow!("Failed to generate TOTP secret bytes: {:?}", e))?;
764
765        let totp = TOTP::new(
766            Algorithm::SHA1,
767            6,  // digits
768            1,  // skew (allow ±1 step)
769            30, // step (seconds)
770            secret_bytes,
771            Some(issuer.to_string()),
772            account_name.to_string(),
773        )
774        .map_err(|e| anyhow::anyhow!("Failed to create TOTP: {:?}", e))?;
775
776        // Store the base32-encoded secret in the vault.
777        let encoded = secret.to_encoded().to_string();
778        self.store_secret(Self::TOTP_SECRET_KEY, &encoded)?;
779
780        Ok(totp.get_url())
781    }
782
783    /// Verify a 6-digit TOTP code against the stored secret.
784    /// Returns `Ok(true)` if the code is valid, `Ok(false)` if invalid,
785    /// or an error if no TOTP secret is configured.
786    pub fn verify_totp(&mut self, code: &str) -> Result<bool> {
787        let encoded = self
788            .get_secret(Self::TOTP_SECRET_KEY, true)?
789            .ok_or_else(|| anyhow::anyhow!("No TOTP secret configured"))?;
790
791        let secret = TotpSecret::Encoded(encoded);
792        let secret_bytes = secret
793            .to_bytes()
794            .map_err(|e| anyhow::anyhow!("Corrupted TOTP secret: {:?}", e))?;
795
796        let totp = TOTP::new(
797            Algorithm::SHA1,
798            6,
799            1,
800            30,
801            secret_bytes,
802            Some("RustyClaw".to_string()),
803            String::new(),
804        )
805        .map_err(|e| anyhow::anyhow!("Failed to create TOTP: {:?}", e))?;
806
807        let now = std::time::SystemTime::now()
808            .duration_since(std::time::UNIX_EPOCH)
809            .context("System time error")?
810            .as_secs();
811
812        Ok(totp.check(code, now))
813    }
814
815    /// Check whether a TOTP secret is stored in the vault.
816    pub fn has_totp(&mut self) -> bool {
817        self.get_secret(Self::TOTP_SECRET_KEY, true)
818            .ok()
819            .flatten()
820            .is_some()
821    }
822
823    /// Remove the stored TOTP secret (disables 2FA).
824    pub fn remove_totp(&mut self) -> Result<()> {
825        if self.has_totp() {
826            self.delete_secret(Self::TOTP_SECRET_KEY)?;
827        }
828        Ok(())
829    }
830
831    /// No-op kept for API compatibility.  The securestore crate
832    /// decrypts on-demand so there is no separate cache to clear.
833    pub fn clear_cache(&mut self) {}
834
835    // ── Browser-style credential storage ────────────────────────────
836
837    /// The vault key used to store the browser credential store.
838    const BROWSER_STORE_KEY: &'static str = "__rustyclaw_browser_store";
839
840    /// Load the browser store from the vault, or create a new empty one.
841    pub fn load_browser_store(&mut self) -> Result<super::types::BrowserStore> {
842        match self.get_secret(Self::BROWSER_STORE_KEY, true)? {
843            Some(json) => {
844                let mut store: super::types::BrowserStore =
845                    serde_json::from_str(&json).context("Corrupted browser store")?;
846                // Purge expired cookies on load
847                store.purge_expired();
848                Ok(store)
849            }
850            None => Ok(super::types::BrowserStore::new()),
851        }
852    }
853
854    /// Save the browser store to the vault.
855    pub fn save_browser_store(&mut self, store: &super::types::BrowserStore) -> Result<()> {
856        let json = serde_json::to_string(store).context("Failed to serialize browser store")?;
857        self.store_secret(Self::BROWSER_STORE_KEY, &json)
858    }
859
860    /// Get cookies for a domain, respecting access policy.
861    ///
862    /// Returns cookies that match the domain (including subdomain matching).
863    /// Access is controlled by the same agent_access / user_approved rules
864    /// as regular secrets.
865    pub fn get_cookies_for_domain(
866        &mut self,
867        domain: &str,
868        path: &str,
869        user_approved: bool,
870    ) -> Result<Vec<super::types::Cookie>> {
871        if !self.agent_access_enabled && !user_approved {
872            anyhow::bail!("Access denied: agent access to browser cookies requires approval");
873        }
874
875        let store = self.load_browser_store()?;
876        Ok(store
877            .get_cookies(domain, path)
878            .into_iter()
879            .cloned()
880            .collect())
881    }
882
883    /// Set a cookie, respecting access policy.
884    pub fn set_cookie(&mut self, cookie: super::types::Cookie, user_approved: bool) -> Result<()> {
885        if !self.agent_access_enabled && !user_approved {
886            anyhow::bail!("Access denied: agent access to browser cookies requires approval");
887        }
888
889        let mut store = self.load_browser_store()?;
890        store.set_cookie(cookie);
891        self.save_browser_store(&store)
892    }
893
894    /// Remove a cookie.
895    pub fn remove_cookie(
896        &mut self,
897        domain: &str,
898        name: &str,
899        path: &str,
900        user_approved: bool,
901    ) -> Result<()> {
902        if !self.agent_access_enabled && !user_approved {
903            anyhow::bail!("Access denied: agent access to browser cookies requires approval");
904        }
905
906        let mut store = self.load_browser_store()?;
907        store.remove_cookie(domain, name, path);
908        self.save_browser_store(&store)
909    }
910
911    /// Clear all cookies for a domain.
912    pub fn clear_domain_cookies(&mut self, domain: &str, user_approved: bool) -> Result<()> {
913        if !self.agent_access_enabled && !user_approved {
914            anyhow::bail!("Access denied: agent access to browser cookies requires approval");
915        }
916
917        let mut store = self.load_browser_store()?;
918        store.clear_cookies(domain);
919        self.save_browser_store(&store)
920    }
921
922    /// Build a Cookie header string for a request.
923    ///
924    /// This is the primary method used by web_fetch to attach cookies.
925    /// Returns None if no cookies match or access is denied.
926    pub fn cookie_header_for_request(
927        &mut self,
928        domain: &str,
929        path: &str,
930        is_secure: bool,
931        user_approved: bool,
932    ) -> Result<Option<String>> {
933        if !self.agent_access_enabled && !user_approved {
934            return Ok(None);
935        }
936
937        let store = self.load_browser_store()?;
938        Ok(store.cookie_header(domain, path, is_secure))
939    }
940
941    /// Parse Set-Cookie headers from a response and store them.
942    ///
943    /// `response_domain` is the domain the response came from.
944    /// Cookies with mismatched domains are rejected (browser security).
945    pub fn store_cookies_from_response(
946        &mut self,
947        response_domain: &str,
948        set_cookie_headers: &[String],
949        user_approved: bool,
950    ) -> Result<usize> {
951        if !self.agent_access_enabled && !user_approved {
952            return Ok(0);
953        }
954
955        let mut store = self.load_browser_store()?;
956        let mut count = 0;
957
958        for header in set_cookie_headers {
959            if let Some(cookie) = Self::parse_set_cookie(header, response_domain) {
960                // Security check: cookie domain must be valid for response domain
961                if Self::is_valid_cookie_domain(&cookie.domain, response_domain) {
962                    store.set_cookie(cookie);
963                    count += 1;
964                }
965            }
966        }
967
968        if count > 0 {
969            self.save_browser_store(&store)?;
970        }
971
972        Ok(count)
973    }
974
975    /// Parse a Set-Cookie header into a Cookie struct.
976    fn parse_set_cookie(header: &str, default_domain: &str) -> Option<super::types::Cookie> {
977        let parts: Vec<&str> = header.split(';').collect();
978        if parts.is_empty() {
979            return None;
980        }
981
982        // First part is name=value
983        let name_value: Vec<&str> = parts[0].splitn(2, '=').collect();
984        if name_value.len() != 2 {
985            return None;
986        }
987
988        let name = name_value[0].trim().to_string();
989        let value = name_value[1].trim().to_string();
990
991        if name.is_empty() {
992            return None;
993        }
994
995        let mut cookie = super::types::Cookie::new(name, value, default_domain);
996
997        // Parse attributes
998        for part in parts.iter().skip(1) {
999            let attr: Vec<&str> = part.splitn(2, '=').collect();
1000            let attr_name = attr[0].trim().to_lowercase();
1001            let attr_value = attr.get(1).map(|v| v.trim()).unwrap_or("");
1002
1003            match attr_name.as_str() {
1004                "domain" => {
1005                    let domain = attr_value.to_lowercase();
1006                    // Normalize: ensure leading dot for subdomain matching
1007                    cookie.domain = if domain.starts_with('.') {
1008                        domain
1009                    } else {
1010                        format!(".{}", domain)
1011                    };
1012                }
1013                "path" => {
1014                    cookie.path = attr_value.to_string();
1015                }
1016                "expires" => {
1017                    // Parse HTTP date — simplified, just use a long expiry
1018                    // Real implementation would parse the date properly
1019                    if let Ok(ts) = httpdate::parse_http_date(attr_value) {
1020                        if let Ok(duration) = ts.duration_since(std::time::UNIX_EPOCH) {
1021                            cookie.expires = Some(duration.as_secs() as i64);
1022                        }
1023                    }
1024                }
1025                "max-age" => {
1026                    if let Ok(secs) = attr_value.parse::<i64>() {
1027                        let now = std::time::SystemTime::now()
1028                            .duration_since(std::time::UNIX_EPOCH)
1029                            .map(|d| d.as_secs() as i64)
1030                            .unwrap_or(0);
1031                        cookie.expires = Some(now + secs);
1032                    }
1033                }
1034                "secure" => {
1035                    cookie.secure = true;
1036                }
1037                "httponly" => {
1038                    cookie.http_only = true;
1039                }
1040                "samesite" => {
1041                    cookie.same_site = attr_value.to_lowercase();
1042                }
1043                _ => {}
1044            }
1045        }
1046
1047        Some(cookie)
1048    }
1049
1050    /// Check if a cookie domain is valid for the response domain.
1051    /// Implements browser security rules.
1052    fn is_valid_cookie_domain(cookie_domain: &str, response_domain: &str) -> bool {
1053        let cookie_domain = cookie_domain.to_lowercase();
1054        let response_domain = response_domain.to_lowercase();
1055
1056        // Remove leading dot for comparison
1057        let cookie_base = cookie_domain.trim_start_matches('.');
1058        let response_base = response_domain.trim_start_matches('.');
1059
1060        // Exact match
1061        if cookie_base == response_base {
1062            return true;
1063        }
1064
1065        // Cookie domain must be a suffix of response domain
1066        // e.g., response "api.github.com" can set cookie for ".github.com"
1067        if response_base.ends_with(&format!(".{}", cookie_base)) {
1068            return true;
1069        }
1070
1071        false
1072    }
1073
1074    /// List all domains that have stored cookies.
1075    pub fn list_cookie_domains(&mut self) -> Result<Vec<String>> {
1076        let store = self.load_browser_store()?;
1077        Ok(store.cookie_domains().into_iter().cloned().collect())
1078    }
1079
1080    // ── Web storage (localStorage equivalent) ───────────────────────
1081
1082    /// Get a value from origin-scoped storage.
1083    pub fn storage_get(
1084        &mut self,
1085        origin: &str,
1086        key: &str,
1087        user_approved: bool,
1088    ) -> Result<Option<String>> {
1089        if !self.agent_access_enabled && !user_approved {
1090            anyhow::bail!("Access denied: agent access to web storage requires approval");
1091        }
1092
1093        let store = self.load_browser_store()?;
1094        Ok(store.storage(origin).and_then(|s| s.get(key).cloned()))
1095    }
1096
1097    /// Set a value in origin-scoped storage.
1098    pub fn storage_set(
1099        &mut self,
1100        origin: &str,
1101        key: &str,
1102        value: &str,
1103        user_approved: bool,
1104    ) -> Result<()> {
1105        if !self.agent_access_enabled && !user_approved {
1106            anyhow::bail!("Access denied: agent access to web storage requires approval");
1107        }
1108
1109        let mut store = self.load_browser_store()?;
1110        store.storage_mut(origin).set(key, value);
1111        self.save_browser_store(&store)
1112    }
1113
1114    /// Remove a value from origin-scoped storage.
1115    pub fn storage_remove(&mut self, origin: &str, key: &str, user_approved: bool) -> Result<()> {
1116        if !self.agent_access_enabled && !user_approved {
1117            anyhow::bail!("Access denied: agent access to web storage requires approval");
1118        }
1119
1120        let mut store = self.load_browser_store()?;
1121        store.storage_mut(origin).remove(key);
1122        self.save_browser_store(&store)
1123    }
1124
1125    /// Clear all storage for an origin.
1126    pub fn storage_clear(&mut self, origin: &str, user_approved: bool) -> Result<()> {
1127        if !self.agent_access_enabled && !user_approved {
1128            anyhow::bail!("Access denied: agent access to web storage requires approval");
1129        }
1130
1131        let mut store = self.load_browser_store()?;
1132        store.clear_storage(origin);
1133        self.save_browser_store(&store)
1134    }
1135
1136    /// List all origins that have stored data.
1137    pub fn list_storage_origins(&mut self) -> Result<Vec<String>> {
1138        let store = self.load_browser_store()?;
1139        Ok(store.storage_origins().into_iter().cloned().collect())
1140    }
1141
1142    /// List all keys in storage for an origin.
1143    pub fn storage_keys(&mut self, origin: &str, user_approved: bool) -> Result<Vec<String>> {
1144        if !self.agent_access_enabled && !user_approved {
1145            anyhow::bail!("Access denied: agent access to web storage requires approval");
1146        }
1147
1148        let store = self.load_browser_store()?;
1149        Ok(store
1150            .storage(origin)
1151            .map(|s| s.keys().cloned().collect())
1152            .unwrap_or_default())
1153    }
1154}