Skip to main content

agentmux_srv/identity/
browser_credential_store.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OS-keychain-backed storage for browser-pane HTTP Basic-Auth credentials,
5//! scoped per identity so one agent's saved site credential is never
6//! reachable through another identity's browsing session.
7//!
8//! Reuses [`crate::identity::secret_store`]'s existing `keyring`-crate
9//! wrapper (already backing `SecretRef::Keychain` for account API keys) —
10//! this module only builds the right account-key string and JSON-encodes
11//! the `{username, password}` pair; no new keychain code, no filesystem
12//! path involved (so `agentmux_common::data_paths::sanitize_path_segment`
13//! genuinely doesn't apply here — nothing is written to disk).
14//!
15//! See docs/status/majestic-painting-minsky plan (credential-isolated
16//! browser-pane auto-fill) and
17//! docs/specs/SPEC_BROWSER_PANE_HTTP_BASIC_AUTH_2026_05_18.md (the v1 spec
18//! that named "Persisted credential store (OS keyring)" as deferred future
19//! work — this module is that follow-up).
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use zeroize::Zeroize;
24
25use crate::identity::secret_store;
26
27/// A saved browser credential. `Zeroize`s on drop so a decoded value
28/// doesn't linger in memory longer than the caller actually needs it —
29/// mirrors `secret_store::get`'s own `Zeroizing<String>` discipline for
30/// the raw keychain read.
31#[derive(Serialize, Deserialize, Zeroize)]
32#[zeroize(drop)]
33pub struct BrowserCredential {
34    pub username: String,
35    pub password: String,
36}
37
38/// Build the keychain account string for one (identity, protection-space)
39/// pair. Deliberately NOT naive `:`-concatenation of the raw fields —
40/// `origin` itself contains `:` (`https://host:8443`) and `realm` is
41/// server-supplied, effectively attacker-influenced, free text, so two
42/// different (origin, realm) pairs could otherwise concatenate to the same
43/// string (e.g. `origin="https://a:1"` + `realm="b"` vs
44/// `origin="https://a"` + `realm="1:b"`). Hashing a NUL-separated tuple
45/// removes any field-boundary ambiguity — NUL can't appear in either field
46/// in practice, but even if it could, the hash input's own delimiter
47/// collision risk is the same NUL-in-a-field edge case for both operands,
48/// which cancels out (unlike `:`, which is valid content in `origin`).
49///
50/// `identity_id` is kept as a literal prefix (not folded into the hash)
51/// so a corrupted/missing hash can't accidentally resolve under a
52/// different identity, and so the account string stays legible in
53/// diagnostic contexts (e.g. `keyring`-crate error messages) without
54/// revealing the origin/realm themselves — only which identity owns the
55/// entry.
56fn account_key(identity_id: &str, origin: &str, realm: &str, is_proxy: bool) -> String {
57    let mut hasher = Sha256::new();
58    hasher.update(origin.as_bytes());
59    hasher.update([0u8]);
60    hasher.update(realm.as_bytes());
61    hasher.update([0u8]);
62    hasher.update(if is_proxy { b"1" } else { b"0" });
63    let digest_hex = hex::encode(hasher.finalize());
64    format!("browser-auth:{identity_id}:{digest_hex}")
65}
66
67/// Store (or overwrite) `username`/`password` for this identity +
68/// protection-space. Unbounded (no timeout) — see `secret_store`'s doc
69/// comment for why a write must not race a timed-out-but-later-completing
70/// keychain call.
71pub fn save(
72    identity_id: &str,
73    origin: &str,
74    realm: &str,
75    is_proxy: bool,
76    username: &str,
77    password: &str,
78) -> Result<(), String> {
79    let key = account_key(identity_id, origin, realm, is_proxy);
80    let cred = BrowserCredential {
81        username: username.to_string(),
82        password: password.to_string(),
83    };
84    let json = serde_json::to_string(&cred).map_err(|e| format!("encode credential: {e}"))?;
85    secret_store::put(&key, &json)
86}
87
88/// Look up a stored credential. `Ok(None)` means "nothing saved for this
89/// identity + protection-space" (not an error) — same contract as
90/// `secret_store::get_optional`, which this wraps directly so a transient
91/// keychain failure (locked keychain, no Secret Service daemon, permission
92/// denied) is distinguishable from "genuinely never saved" at the call
93/// site, the same distinction `secret_store`'s own doc comment argues for.
94pub fn load(
95    identity_id: &str,
96    origin: &str,
97    realm: &str,
98    is_proxy: bool,
99) -> Result<Option<BrowserCredential>, String> {
100    let key = account_key(identity_id, origin, realm, is_proxy);
101    let Some(json) = secret_store::get_optional(&key)? else {
102        return Ok(None);
103    };
104    let cred: BrowserCredential =
105        serde_json::from_str(&json).map_err(|e| format!("decode credential: {e}"))?;
106    Ok(Some(cred))
107}
108
109/// Delete the stored credential for this identity + protection-space.
110/// Idempotent (a missing entry is success) — same contract as
111/// `secret_store::delete`. Called when a stored credential turns out to be
112/// wrong (a re-challenge shortly after an auto-fill — see the
113/// credential_broker orchestration in agentmux-cef).
114pub fn delete(identity_id: &str, origin: &str, realm: &str, is_proxy: bool) -> Result<(), String> {
115    let key = account_key(identity_id, origin, realm, is_proxy);
116    secret_store::delete(&key)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn account_key_is_namespaced_by_identity() {
125        let a = account_key("identity-a", "https://example.com", "realm", false);
126        let b = account_key("identity-b", "https://example.com", "realm", false);
127        assert_ne!(a, b, "different identities must never share an account key");
128        assert!(a.starts_with("browser-auth:identity-a:"));
129        assert!(b.starts_with("browser-auth:identity-b:"));
130    }
131
132    #[test]
133    fn account_key_is_stable_for_identical_inputs() {
134        let a = account_key("id", "https://example.com", "realm", true);
135        let b = account_key("id", "https://example.com", "realm", true);
136        assert_eq!(a, b);
137    }
138
139    #[test]
140    fn account_key_distinguishes_is_proxy() {
141        let a = account_key("id", "https://example.com", "realm", true);
142        let b = account_key("id", "https://example.com", "realm", false);
143        assert_ne!(a, b, "proxy-auth and origin-server-auth caches must not collide");
144    }
145
146    /// The exact bug naive `format!("{origin}:{realm}")` concatenation
147    /// would have: `origin` itself contains `:` (`https://host:port`), so
148    /// two different (origin, realm) pairs can produce the same
149    /// concatenated string. The NUL-separated-then-hashed scheme must not
150    /// have this problem.
151    #[test]
152    fn account_key_does_not_collide_across_delimiter_boundaries() {
153        let a = account_key("id", "https://a:1", "b", false);
154        let b = account_key("id", "https://a", "1:b", false);
155        assert_ne!(
156            a, b,
157            "origin/realm field-boundary shift must not collide (naive ':' concat would fail this)"
158        );
159    }
160
161    #[test]
162    fn account_key_distinguishes_origin() {
163        let a = account_key("id", "https://example.com", "realm", false);
164        let b = account_key("id", "https://other.com", "realm", false);
165        assert_ne!(a, b);
166    }
167
168    #[test]
169    fn account_key_distinguishes_realm() {
170        let a = account_key("id", "https://example.com", "realm-a", false);
171        let b = account_key("id", "https://example.com", "realm-b", false);
172        assert_ne!(a, b);
173    }
174
175    #[test]
176    fn browser_credential_json_round_trips() {
177        let cred = BrowserCredential {
178            username: "user".to_string(),
179            password: "pass".to_string(),
180        };
181        let json = serde_json::to_string(&cred).unwrap();
182        let decoded: BrowserCredential = serde_json::from_str(&json).unwrap();
183        assert_eq!(decoded.username, "user");
184        assert_eq!(decoded.password, "pass");
185    }
186}