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