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}