Skip to main content

brows3r_lib/profiles/
keychain.rs

1//! OS keychain integration with encrypted-file fallback.
2//!
3//! # Architecture
4//!
5//! [`KeychainBackend`] is a trait with three operations: `set`, `get`,
6//! `delete`. Three concrete implementations live here:
7//!
8//! - [`KeyringBackend`] — wraps the `keyring` crate; active on macOS
9//!   (Keychain), Windows (Credential Manager), and Linux (Secret Service).
10//! - `FileBackend` — AES-256-GCM encrypted `secrets.enc` sidecar; used
11//!   when `KeyringBackend` init fails (headless Linux, CI, locked DBus).
12//!   Passphrase is supplied by the caller; prompting the user is deferred
13//!   to the Credential Manager UI in task 18.
14//! - `StubBackend` — in-memory `HashMap` for unit tests; gated behind
15//!   the `test-keyring-stub` cargo feature.
16//!
17//! # OCP contract
18//!
19//! Adding a new backend (e.g. `OnePasswordBackend`) requires only:
20//!   1. A new struct implementing `KeychainBackend`.
21//!   2. Optionally, extending `select_backend` to return it.
22//! No existing code changes.
23//!
24//! # Security contract
25//!
26//! [`Secret`] carries `#[serde(skip_serializing)]` on every field so it
27//! can never be emitted across Tauri IPC by accident. Fields are zeroed in
28//! memory on drop via [`zeroize::ZeroizeOnDrop`].
29//!
30//! Internal storage (keyring JSON blob, FileBackend map) uses `StoredSecret`,
31//! a private mirror that CAN serialize all fields. The two structs are
32//! intentionally separate to enforce the IPC-safe contract on `Secret`.
33
34use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37use zeroize::{Zeroize, ZeroizeOnDrop};
38
39use crate::error::AppError;
40
41// ---------------------------------------------------------------------------
42// Secret — IPC-safe credential payload (fields skip_serializing)
43// ---------------------------------------------------------------------------
44
45/// AWS / provider credentials stored by a profile.
46///
47/// Every field carries `#[serde(skip_serializing)]` so the struct, when
48/// serialized via Tauri IPC (e.g. returned from a command), never leaks
49/// credentials. Memory is zeroed on drop via [`ZeroizeOnDrop`].
50///
51/// Internal storage backends use `StoredSecret` to persist the actual values.
52#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
53pub struct Secret {
54    #[serde(skip_serializing)]
55    pub access_key_id: String,
56    #[serde(skip_serializing)]
57    pub secret_access_key: String,
58    #[serde(skip_serializing)]
59    pub session_token: Option<String>,
60}
61
62/// Private mirror of [`Secret`] used for serialization inside storage
63/// backends. All fields are serialized normally. Never exposed over IPC.
64#[derive(Debug, Serialize, Deserialize)]
65struct StoredSecret {
66    access_key_id: String,
67    secret_access_key: String,
68    session_token: Option<String>,
69}
70
71impl From<&Secret> for StoredSecret {
72    fn from(s: &Secret) -> Self {
73        Self {
74            access_key_id: s.access_key_id.clone(),
75            secret_access_key: s.secret_access_key.clone(),
76            session_token: s.session_token.clone(),
77        }
78    }
79}
80
81impl From<StoredSecret> for Secret {
82    fn from(s: StoredSecret) -> Self {
83        Self {
84            access_key_id: s.access_key_id,
85            secret_access_key: s.secret_access_key,
86            session_token: s.session_token,
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// KeychainBackend trait
93// ---------------------------------------------------------------------------
94
95/// Backend-agnostic interface for persisting and retrieving credential
96/// secrets keyed by profile ID.
97pub trait KeychainBackend: Send + Sync {
98    /// Persist `secret` under `profile_id`, replacing any existing entry.
99    fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError>;
100
101    /// Retrieve the secret for `profile_id`, or `None` if not present.
102    fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError>;
103
104    /// Remove the secret for `profile_id`. No-op if it does not exist.
105    fn delete(&mut self, profile_id: &str) -> Result<(), AppError>;
106
107    /// Supply a passphrase to unlock a passphrase-protected backend.
108    ///
109    /// For backends that do not require a passphrase (e.g. `KeyringBackend`,
110    /// `StubBackend`) this is a no-op that always returns `Ok(())`.
111    ///
112    /// `FileBackend` overrides this to re-derive the encryption key from the
113    /// supplied passphrase and attempt to decrypt the secrets file.
114    ///
115    /// Called by `keychain_fallback_unlock` in response to the user submitting
116    /// the `KeychainFallbackPrompt` in the Credential Manager UI.
117    fn unlock(&mut self, _passphrase: &str) -> Result<(), AppError> {
118        Ok(())
119    }
120}
121
122// ---------------------------------------------------------------------------
123// KeyringBackend — OS keychain via the `keyring` crate
124// ---------------------------------------------------------------------------
125
126/// Wraps the [`keyring`] crate to store one entry per profile.
127///
128/// Service name is fixed at `"brows3r"`. The per-entry credential name is
129/// `"profile:<profile_id>"`.
130///
131/// Secrets are serialized as `StoredSecret` JSON before storage; the
132/// `keyring` crate treats its value as an opaque password string, so JSON is
133/// the simplest portable encoding.
134pub struct KeyringBackend;
135
136impl KeyringBackend {
137    /// Construct a new `KeyringBackend`. The service name `"brows3r"` is
138    /// hard-coded; profile-specific entry names are derived at call time.
139    pub fn new() -> Self {
140        Self
141    }
142
143    fn entry_name(profile_id: &str) -> String {
144        format!("profile:{profile_id}")
145    }
146}
147
148impl Default for KeyringBackend {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl KeychainBackend for KeyringBackend {
155    fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
156        let stored = StoredSecret::from(secret);
157        let json = serde_json::to_string(&stored).map_err(|e| AppError::Internal {
158            trace_id: format!("keyring::set::serialize:{e}"),
159        })?;
160        keyring::Entry::new("brows3r", &Self::entry_name(profile_id))
161            .map_err(|e| AppError::Internal {
162                trace_id: format!("keyring::set::entry:{e}"),
163            })?
164            .set_password(&json)
165            .map_err(|e| AppError::Internal {
166                trace_id: format!("keyring::set::write:{e}"),
167            })
168    }
169
170    fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
171        let entry = keyring::Entry::new("brows3r", &Self::entry_name(profile_id)).map_err(|e| {
172            AppError::Internal {
173                trace_id: format!("keyring::get::entry:{e}"),
174            }
175        })?;
176        match entry.get_password() {
177            Ok(json) => {
178                let stored: StoredSecret =
179                    serde_json::from_str(&json).map_err(|e| AppError::Internal {
180                        trace_id: format!("keyring::get::deserialize:{e}"),
181                    })?;
182                Ok(Some(Secret::from(stored)))
183            }
184            Err(keyring::Error::NoEntry) => Ok(None),
185            Err(e) => Err(AppError::Internal {
186                trace_id: format!("keyring::get::read:{e}"),
187            }),
188        }
189    }
190
191    fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
192        let entry = keyring::Entry::new("brows3r", &Self::entry_name(profile_id)).map_err(|e| {
193            AppError::Internal {
194                trace_id: format!("keyring::delete::entry:{e}"),
195            }
196        })?;
197        match entry.delete_credential() {
198            Ok(()) => Ok(()),
199            // Deleting a non-existent entry is not an error.
200            Err(keyring::Error::NoEntry) => Ok(()),
201            Err(e) => Err(AppError::Internal {
202                trace_id: format!("keyring::delete::remove:{e}"),
203            }),
204        }
205    }
206}
207
208// ---------------------------------------------------------------------------
209// FileBackend — passphrase-encrypted secrets.enc sidecar
210// ---------------------------------------------------------------------------
211//
212// Encryption scheme:
213//   - Key derivation: Argon2id with a per-file random 32-byte salt.
214//   - Cipher: AES-256-GCM with a random 12-byte nonce per write.
215//   - Layout of the binary file:
216//       [32 bytes salt][12 bytes nonce][ciphertext]
217//   - Plaintext is the JSON-serialized `BTreeMap<String, StoredSecret>`.
218//
219// The backend keeps the plaintext map in memory after first decrypt; every
220// `set`/`delete` re-encrypts and overwrites the file.
221
222use aes_gcm::{
223    aead::{Aead, AeadCore, KeyInit, OsRng},
224    Aes256Gcm, Key, Nonce,
225};
226use argon2::{Argon2, Params};
227
228/// Passphrase-encrypted file-based fallback for environments where the OS
229/// keychain is unavailable.
230///
231/// Secrets are stored in `${path}/secrets.enc` as an AES-256-GCM blob whose
232/// key is derived from `passphrase` via Argon2id. The entire map is
233/// re-encrypted on every `set` / `delete`.
234///
235/// This backend is not exposed directly; callers use
236/// [`FileBackendWithPassphrase`] which stores the passphrase securely and
237/// implements the full [`KeychainBackend`] trait.
238struct FileBackend {
239    /// Directory where `secrets.enc` lives.
240    path: std::path::PathBuf,
241    /// Argon2id-derived 32-byte AES-256-GCM key (zero-salt placeholder,
242    /// overridden on load from the real per-file salt).
243    key_bytes: [u8; 32],
244    /// In-memory mirror of the decrypted map; populated lazily.
245    map: BTreeMap<String, StoredSecret>,
246    /// True after the file has been loaded (or determined to not exist).
247    loaded: bool,
248}
249
250impl FileBackend {
251    fn new(dir: impl Into<std::path::PathBuf>, passphrase: &str) -> Self {
252        // Store a placeholder key derived from a zero salt. The real key is
253        // re-derived from the on-disk salt in ensure_loaded.
254        let key_bytes = derive_key(passphrase, &[0u8; 32]);
255        Self {
256            path: dir.into(),
257            key_bytes,
258            map: BTreeMap::new(),
259            loaded: false,
260        }
261    }
262
263    fn file_path(&self) -> std::path::PathBuf {
264        self.path.join("secrets.enc")
265    }
266
267    /// Load and decrypt the file into `self.map`. Idempotent after first call.
268    fn ensure_loaded(&mut self, passphrase: &str) -> Result<(), AppError> {
269        if self.loaded {
270            return Ok(());
271        }
272        self.loaded = true;
273
274        let file_path = self.file_path();
275        if !file_path.exists() {
276            return Ok(());
277        }
278
279        let blob = std::fs::read(&file_path).map_err(|e| AppError::Internal {
280            trace_id: format!("file_backend::read:{e}"),
281        })?;
282
283        // Layout: [32 salt][12 nonce][ciphertext]
284        if blob.len() < 44 {
285            return Err(AppError::Internal {
286                trace_id: "file_backend::truncated_blob".to_string(),
287            });
288        }
289
290        let salt: [u8; 32] = blob[..32].try_into().unwrap();
291        let nonce_bytes: [u8; 12] = blob[32..44].try_into().unwrap();
292        let ciphertext = &blob[44..];
293
294        let key_bytes = derive_key(passphrase, &salt);
295        // Update the stored key to match the on-disk salt.
296        self.key_bytes = key_bytes;
297
298        let key: Key<Aes256Gcm> = key_bytes.into();
299        let cipher = Aes256Gcm::new(&key);
300        let nonce = Nonce::from_slice(&nonce_bytes);
301
302        let plaintext = cipher
303            .decrypt(nonce, ciphertext)
304            .map_err(|_| AppError::Auth {
305                reason: "invalid passphrase or corrupted secrets file".to_string(),
306            })?;
307
308        self.map = serde_json::from_slice(&plaintext).map_err(|e| AppError::Internal {
309            trace_id: format!("file_backend::deserialize:{e}"),
310        })?;
311
312        Ok(())
313    }
314
315    /// Serialize and encrypt `self.map`, then write `secrets.enc`.
316    fn flush(&self, passphrase: &str) -> Result<(), AppError> {
317        let json = serde_json::to_vec(&self.map).map_err(|e| AppError::Internal {
318            trace_id: format!("file_backend::serialize:{e}"),
319        })?;
320
321        // Generate a fresh random salt and nonce on every write.
322        let salt: [u8; 32] = {
323            let mut s = [0u8; 32];
324            use aes_gcm::aead::rand_core::RngCore;
325            OsRng.fill_bytes(&mut s);
326            s
327        };
328
329        let key_bytes = derive_key(passphrase, &salt);
330        let key: Key<Aes256Gcm> = key_bytes.into();
331        let cipher = Aes256Gcm::new(&key);
332        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
333
334        let ciphertext = cipher
335            .encrypt(&nonce, json.as_ref())
336            .map_err(|e| AppError::Internal {
337                trace_id: format!("file_backend::encrypt:{e}"),
338            })?;
339
340        // Layout: [32 salt][12 nonce][ciphertext]
341        let mut blob = Vec::with_capacity(44 + ciphertext.len());
342        blob.extend_from_slice(&salt);
343        blob.extend_from_slice(&nonce);
344        blob.extend_from_slice(&ciphertext);
345
346        if let Some(parent) = self.file_path().parent() {
347            std::fs::create_dir_all(parent).map_err(|e| AppError::Internal {
348                trace_id: format!("file_backend::mkdir:{e}"),
349            })?;
350        }
351        std::fs::write(self.file_path(), &blob).map_err(|e| AppError::Internal {
352            trace_id: format!("file_backend::write:{e}"),
353        })
354    }
355}
356
357impl Drop for FileBackend {
358    fn drop(&mut self) {
359        self.key_bytes.zeroize();
360    }
361}
362
363// ---------------------------------------------------------------------------
364// FileBackendWithPassphrase — public wrapper that holds the passphrase
365// ---------------------------------------------------------------------------
366
367/// Public file-based keychain backend.
368///
369/// Wraps `FileBackend` and stores the passphrase as a
370/// [`zeroize::Zeroizing`] string so it is scrubbed from memory on drop.
371pub struct FileBackendWithPassphrase {
372    inner: FileBackend,
373    passphrase: zeroize::Zeroizing<String>,
374}
375
376impl FileBackendWithPassphrase {
377    /// Create a new file-based backend.
378    ///
379    /// `dir` is the directory for `secrets.enc`.
380    /// `passphrase` is the user-supplied passphrase for key derivation; it
381    /// is zeroed on drop. The passphrase prompt UX lands in task 18.
382    pub fn new(dir: impl Into<std::path::PathBuf>, passphrase: impl Into<String>) -> Self {
383        let passphrase: String = passphrase.into();
384        let inner = FileBackend::new(dir, &passphrase);
385        Self {
386            inner,
387            passphrase: zeroize::Zeroizing::new(passphrase),
388        }
389    }
390}
391
392impl KeychainBackend for FileBackendWithPassphrase {
393    fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
394        self.inner.ensure_loaded(self.passphrase.as_str())?;
395        self.inner
396            .map
397            .insert(profile_id.to_string(), StoredSecret::from(secret));
398        self.inner.flush(self.passphrase.as_str())
399    }
400
401    fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
402        // `ensure_loaded` requires `&mut self`. When the map is already loaded
403        // (after any previous set/delete/get via a mut path), serve from
404        // self.inner.map directly. Otherwise decrypt the file on the spot.
405        if self.inner.loaded {
406            return Ok(self.inner.map.get(profile_id).map(|s| {
407                Secret::from(StoredSecret {
408                    access_key_id: s.access_key_id.clone(),
409                    secret_access_key: s.secret_access_key.clone(),
410                    session_token: s.session_token.clone(),
411                })
412            }));
413        }
414
415        let file_path = self.inner.file_path();
416        if !file_path.exists() {
417            return Ok(None);
418        }
419
420        // Decrypt into a temporary map without mutating self.
421        let map = decrypt_file(&file_path, self.passphrase.as_str())?;
422        Ok(map.get(profile_id).map(|s| {
423            Secret::from(StoredSecret {
424                access_key_id: s.access_key_id.clone(),
425                secret_access_key: s.secret_access_key.clone(),
426                session_token: s.session_token.clone(),
427            })
428        }))
429    }
430
431    fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
432        self.inner.ensure_loaded(self.passphrase.as_str())?;
433        self.inner.map.remove(profile_id);
434        self.inner.flush(self.passphrase.as_str())
435    }
436}
437
438/// Decrypt a `secrets.enc` file and return the stored map.
439fn decrypt_file(
440    path: &std::path::Path,
441    passphrase: &str,
442) -> Result<BTreeMap<String, StoredSecret>, AppError> {
443    let blob = std::fs::read(path).map_err(|e| AppError::Internal {
444        trace_id: format!("file_backend::get::read:{e}"),
445    })?;
446
447    if blob.len() < 44 {
448        return Err(AppError::Internal {
449            trace_id: "file_backend::get::truncated_blob".to_string(),
450        });
451    }
452
453    let salt: [u8; 32] = blob[..32].try_into().unwrap();
454    let nonce_bytes: [u8; 12] = blob[32..44].try_into().unwrap();
455    let ciphertext = &blob[44..];
456
457    let key_bytes = derive_key(passphrase, &salt);
458    let key: Key<Aes256Gcm> = key_bytes.into();
459    let cipher = Aes256Gcm::new(&key);
460    let nonce = Nonce::from_slice(&nonce_bytes);
461
462    let plaintext = cipher
463        .decrypt(nonce, ciphertext)
464        .map_err(|_| AppError::Auth {
465            reason: "invalid passphrase or corrupted secrets file".to_string(),
466        })?;
467
468    serde_json::from_slice(&plaintext).map_err(|e| AppError::Internal {
469        trace_id: format!("file_backend::get::deserialize:{e}"),
470    })
471}
472
473// ---------------------------------------------------------------------------
474// Key derivation helpers
475// ---------------------------------------------------------------------------
476
477/// Derive a 32-byte AES-256-GCM key from `passphrase` using Argon2id.
478fn derive_key(passphrase: &str, salt: &[u8; 32]) -> [u8; 32] {
479    let mut key = [0u8; 32];
480    // Argon2id with interactive parameters (64 MiB, 3 iterations, 1 lane).
481    // Parameters are intentionally conservative and can be made configurable
482    // in a future task without changing the file layout (only the salt is
483    // stored, not the Argon2 params).
484    let params = Params::new(
485        65536, // 64 MiB memory cost
486        3,     // 3 iterations
487        1,     // 1 lane (portable default)
488        Some(32),
489    )
490    .expect("argon2 params are valid constants");
491    Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
492        .hash_password_into(passphrase.as_bytes(), salt, &mut key)
493        .expect("argon2 hash_password_into must not fail with valid params");
494    key
495}
496
497// ---------------------------------------------------------------------------
498// StubBackend — in-memory backend for unit tests
499// ---------------------------------------------------------------------------
500
501/// In-memory keychain backend for unit tests.
502///
503/// Gated behind the `test-keyring-stub` cargo feature so that CI never
504/// requires a real OS keychain.
505#[cfg(feature = "test-keyring-stub")]
506pub struct StubBackend {
507    map: std::collections::HashMap<String, Secret>,
508}
509
510#[cfg(feature = "test-keyring-stub")]
511impl StubBackend {
512    pub fn new() -> Self {
513        Self {
514            map: std::collections::HashMap::new(),
515        }
516    }
517}
518
519#[cfg(feature = "test-keyring-stub")]
520impl Default for StubBackend {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526#[cfg(feature = "test-keyring-stub")]
527impl KeychainBackend for StubBackend {
528    fn set(&mut self, profile_id: &str, secret: &Secret) -> Result<(), AppError> {
529        self.map.insert(profile_id.to_string(), secret.clone());
530        Ok(())
531    }
532
533    fn get(&self, profile_id: &str) -> Result<Option<Secret>, AppError> {
534        Ok(self.map.get(profile_id).cloned())
535    }
536
537    fn delete(&mut self, profile_id: &str) -> Result<(), AppError> {
538        self.map.remove(profile_id);
539        Ok(())
540    }
541}
542
543// ---------------------------------------------------------------------------
544// select_backend — factory
545// ---------------------------------------------------------------------------
546
547/// Select the best available keychain backend at runtime.
548///
549/// Probes the OS keychain by writing and deleting a test entry under the
550/// `"brows3r"` service. Returns a [`KeyringBackend`] on success; falls back
551/// to a [`FileBackendWithPassphrase`] otherwise.
552///
553/// # Notes on `fallback_passphrase`
554/// The passphrase prompt UX lands in task 18 (Credential Manager UI). At
555/// this stage the caller supplies the passphrase string directly.
556///
557/// # OCP note
558/// Future selection logic (env-var override, settings flag, 1Password
559/// integration) extends only this function without breaking call sites.
560pub fn select_backend(
561    fallback_dir: impl Into<std::path::PathBuf>,
562    fallback_passphrase: &str,
563) -> (Box<dyn KeychainBackend + Send + Sync>, bool) {
564    // Escape hatch for dev mode: skip the probe and trust the OS keychain.
565    // The keyring crate will still fail-loudly when an actual get/set runs
566    // against a broken keychain, so this is a safe override for the common
567    // case where the probe trips on macOS's per-binary security prompt.
568    if std::env::var("BROWS3R_FORCE_OS_KEYCHAIN").is_ok() {
569        return (Box::new(KeyringBackend::new()), false);
570    }
571
572    // Probe the OS keychain with a read-only lookup. Using a write+delete
573    // pair (the previous approach) was unreliable: on macOS the keyring
574    // crate's `set_password` and `delete_credential` use different lookup
575    // categories, so a freshly-written entry frequently could not be
576    // deleted via its own service/account pair — every dev launch fell
577    // back even though the keychain was perfectly healthy.
578    //
579    // A read for a probe entry that does not exist returns
580    // `keyring::Error::NoEntry`. We treat that — and any successful read
581    // — as "keychain works". Only other error variants (DBus refused,
582    // Security framework denied, init failed, …) trigger the fallback.
583    let probe_err: Option<String> = match keyring::Entry::new("brows3r", "__probe__") {
584        Ok(entry) => match entry.get_password() {
585            Ok(_) => None,
586            Err(keyring::Error::NoEntry) => None,
587            Err(e) => Some(format!("get: {e}")),
588        },
589        Err(e) => Some(format!("new: {e}")),
590    };
591
592    if probe_err.is_none() {
593        (Box::new(KeyringBackend::new()), false)
594    } else {
595        // OS keychain unavailable — use encrypted file fallback.
596        // Per design.md §Cross-Platform Considerations: "off by default,
597        // surfaced via notification when used". The boolean returned alongside
598        // the backend lets the caller emit `KeychainFallbackRequired` so the
599        // KeychainFallbackPrompt opens and the user can supply a real
600        // passphrase. Without that follow-up `secrets.enc` is encrypted with
601        // the placeholder passphrase that lib.rs passes here.
602        eprintln!(
603            "[brows3r] OS keychain unavailable — falling back to encrypted file backend. \
604             Probe error: {}. Until the user supplies a passphrase via the Credential \
605             Manager prompt, secrets.enc is encrypted with the empty placeholder passphrase. \
606             In dev mode this is often caused by macOS prompting per unsigned binary; \
607             set BROWS3R_FORCE_OS_KEYCHAIN=1 to bypass the probe and trust the OS keychain.",
608            probe_err.as_deref().unwrap_or("<unknown>"),
609        );
610        (
611            Box::new(FileBackendWithPassphrase::new(
612                fallback_dir,
613                fallback_passphrase,
614            )),
615            true,
616        )
617    }
618}
619
620// ---------------------------------------------------------------------------
621// Tests
622// ---------------------------------------------------------------------------
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    // ------------------------------------------------------------------
629    // StubBackend — gated behind test-keyring-stub feature
630    // ------------------------------------------------------------------
631
632    #[cfg(feature = "test-keyring-stub")]
633    mod stub_tests {
634        use super::*;
635
636        fn stub() -> StubBackend {
637            StubBackend::new()
638        }
639
640        fn secret(id: &str) -> Secret {
641            Secret {
642                access_key_id: format!("AKIA{id}"),
643                secret_access_key: format!("secret{id}"),
644                session_token: None,
645            }
646        }
647
648        #[test]
649        fn stub_set_and_retrieve() {
650            let mut b = stub();
651            let s = secret("01");
652            b.set("p1", &s).unwrap();
653            let got = b.get("p1").unwrap().expect("should be Some");
654            assert_eq!(got.access_key_id, s.access_key_id);
655            assert_eq!(got.secret_access_key, s.secret_access_key);
656            assert!(got.session_token.is_none());
657        }
658
659        #[test]
660        fn stub_get_missing_returns_none() {
661            let b = stub();
662            assert!(b.get("nonexistent").unwrap().is_none());
663        }
664
665        #[test]
666        fn stub_delete_removes_entry() {
667            let mut b = stub();
668            let s = secret("02");
669            b.set("p2", &s).unwrap();
670            b.delete("p2").unwrap();
671            assert!(b.get("p2").unwrap().is_none());
672        }
673
674        #[test]
675        fn stub_delete_nonexistent_is_noop() {
676            let mut b = stub();
677            b.delete("ghost").unwrap(); // must not error
678        }
679
680        #[test]
681        fn stub_set_overwrites_existing() {
682            let mut b = stub();
683            let s1 = secret("03a");
684            let s2 = secret("03b");
685            b.set("p3", &s1).unwrap();
686            b.set("p3", &s2).unwrap();
687            let got = b.get("p3").unwrap().expect("should be Some");
688            assert_eq!(got.access_key_id, s2.access_key_id);
689        }
690
691        #[test]
692        fn stub_session_token_round_trips() {
693            let mut b = stub();
694            let s = Secret {
695                access_key_id: "AKIATOKEN".to_string(),
696                secret_access_key: "secret".to_string(),
697                session_token: Some("sess-tok-123".to_string()),
698            };
699            b.set("p4", &s).unwrap();
700            let got = b.get("p4").unwrap().unwrap();
701            assert_eq!(got.session_token.as_deref(), Some("sess-tok-123"));
702        }
703    }
704
705    // ------------------------------------------------------------------
706    // FileBackend round-trip tests (always compiled)
707    // ------------------------------------------------------------------
708
709    mod file_tests {
710        use super::*;
711
712        fn secret(id: &str) -> Secret {
713            Secret {
714                access_key_id: format!("AKIA{id}"),
715                secret_access_key: format!("secret{id}"),
716                session_token: None,
717            }
718        }
719
720        #[test]
721        fn file_round_trip_same_passphrase() {
722            let dir = tempfile::tempdir().expect("tempdir");
723            let s = secret("rt");
724
725            {
726                let mut b = FileBackendWithPassphrase::new(dir.path(), "correcthorsebatterystaple");
727                b.set("profile-rt", &s).unwrap();
728            }
729
730            // Re-open with the same passphrase — should get the secret back.
731            let b = FileBackendWithPassphrase::new(dir.path(), "correcthorsebatterystaple");
732            let got = b.get("profile-rt").unwrap().expect("should be Some");
733            assert_eq!(got.access_key_id, s.access_key_id);
734            assert_eq!(got.secret_access_key, s.secret_access_key);
735        }
736
737        #[test]
738        fn file_wrong_passphrase_returns_auth_error() {
739            let dir = tempfile::tempdir().expect("tempdir");
740            let s = secret("wp");
741
742            {
743                let mut b = FileBackendWithPassphrase::new(dir.path(), "correct");
744                b.set("profile-wp", &s).unwrap();
745            }
746
747            let b = FileBackendWithPassphrase::new(dir.path(), "wrong");
748            match b.get("profile-wp") {
749                Err(AppError::Auth { .. }) => {} // expected
750                other => panic!("expected Auth error, got {other:?}"),
751            }
752        }
753
754        #[test]
755        fn file_delete_persists_across_instances() {
756            let dir = tempfile::tempdir().expect("tempdir");
757            let s = secret("del");
758
759            {
760                let mut b = FileBackendWithPassphrase::new(dir.path(), "pass");
761                b.set("profile-del", &s).unwrap();
762            }
763
764            {
765                let mut b = FileBackendWithPassphrase::new(dir.path(), "pass");
766                b.delete("profile-del").unwrap();
767            }
768
769            let b = FileBackendWithPassphrase::new(dir.path(), "pass");
770            assert!(b.get("profile-del").unwrap().is_none());
771        }
772
773        #[test]
774        fn file_multiple_profiles_coexist() {
775            let dir = tempfile::tempdir().expect("tempdir");
776
777            {
778                let mut b = FileBackendWithPassphrase::new(dir.path(), "multi-pass");
779                b.set("p-a", &secret("A")).unwrap();
780                b.set("p-b", &secret("B")).unwrap();
781            }
782
783            let b = FileBackendWithPassphrase::new(dir.path(), "multi-pass");
784            assert_eq!(b.get("p-a").unwrap().unwrap().access_key_id, "AKIAA");
785            assert_eq!(b.get("p-b").unwrap().unwrap().access_key_id, "AKIAB");
786        }
787
788        #[test]
789        fn file_session_token_round_trips() {
790            let dir = tempfile::tempdir().expect("tempdir");
791
792            let s = Secret {
793                access_key_id: "AKIATOKEN".to_string(),
794                secret_access_key: "secret".to_string(),
795                session_token: Some("sess-tok-abc".to_string()),
796            };
797
798            {
799                let mut b = FileBackendWithPassphrase::new(dir.path(), "tok-pass");
800                b.set("p-tok", &s).unwrap();
801            }
802
803            let b = FileBackendWithPassphrase::new(dir.path(), "tok-pass");
804            let got = b.get("p-tok").unwrap().unwrap();
805            assert_eq!(got.session_token.as_deref(), Some("sess-tok-abc"));
806        }
807    }
808
809    // ------------------------------------------------------------------
810    // Secret IPC serialization safety
811    // ------------------------------------------------------------------
812
813    #[test]
814    fn secret_fields_are_not_serialized_to_json() {
815        let s = Secret {
816            access_key_id: "AKIATEST".to_string(),
817            secret_access_key: "supersecret".to_string(),
818            session_token: Some("tok".to_string()),
819        };
820        let json = serde_json::to_string(&s).expect("serialize");
821        assert!(
822            !json.contains("AKIATEST"),
823            "access_key_id must not appear in serialized output: {json}"
824        );
825        assert!(
826            !json.contains("supersecret"),
827            "secret_access_key must not appear in serialized output: {json}"
828        );
829        assert!(
830            !json.contains("tok"),
831            "session_token must not appear in serialized output: {json}"
832        );
833    }
834
835    // ------------------------------------------------------------------
836    // ZeroizeOnDrop — compile-time contract
837    // ------------------------------------------------------------------
838    //
839    // Runtime memory zeroing is platform-dependent (allocator reuse, etc.).
840    // The standard practice is to verify the bound compiles, which means the
841    // ZeroizeOnDrop impl is present and the compiler enforces the contract.
842
843    #[test]
844    fn secret_implements_zeroize_on_drop() {
845        fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
846        assert_zeroize_on_drop::<Secret>();
847    }
848}