agentmux_srv/server/service/credential.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `credential` service — the paired CEF host's browser-pane Basic-Auth
5//! credential broker calls this, host→srv, to resolve an identity for a
6//! pane and to look up / save / delete / fill a stored site credential.
7//!
8//! **`Fill` is the only method here that ever returns a plaintext
9//! password**, and it's called exactly once per approved use, at the
10//! moment the host is about to hand the credential to CEF's
11//! `AuthCallback::cont()`. `Lookup` deliberately returns only a masked
12//! username — the credential-approval window (agentmux-cef,
13//! `open_subwindow`-based, see `credential_broker` there) uses `Lookup`
14//! to decide what to *display*, and must never receive the real password
15//! even transiently.
16//!
17//! Storage itself lives in [`agentmux_srv::identity::browser_credential_store`]
18//! (OS keychain via the existing `secret_store.rs` wrapper) — this module
19//! is just the `/agentmux/service` RPC surface over it, following the same
20//! shape as [`super::host_ipc`] (the other host→srv service module).
21//!
22//! ## Auth: host-only, NOT the shared `X-AuthKey`
23//!
24//! **Every method on this service requires `args[1]` to be this srv
25//! instance's `AGENTMUX_HOST_REG_SECRET`**, the same host-only credential
26//! `host_ipc.Register` proves (see that module's doc comment and
27//! `Config::host_reg_secret`).
28//!
29//! An earlier cut of this module gated on the instance-wide `X-AuthKey`
30//! alone, reasoning that — unlike `host_ipc.Register`'s
31//! "session-hijack-shaped" port+token — every method here only touches data
32//! scoped to a caller-supplied `identity_id`. **That reasoning was wrong,
33//! and it defeated this feature's entire stated security goal** (reagent P0
34//! on PR #2824). `X-AuthKey` is deliberately re-injected into every spawned
35//! agent's own environment as `AGENTMUX_AUTH_KEY`
36//! (`server/agent_handlers/input.rs`), and `AGENTMUX_BLOCKID` is injected
37//! alongside it. So any agent could, with a plain `curl` from its own Shell
38//! tool:
39//!
40//! 1. `credential.ResolveIdentity` with its own known `block_id` →
41//! `identity_id`
42//! 2. `credential.Fill` with that `identity_id` plus the origin/realm it
43//! already knows (it is, after all, the thing browsing that site) →
44//! **the plaintext password**
45//!
46//! …with no human approval window anywhere in the path. `Fill` uniquely
47//! returns a raw password, which is exactly the class of secret that needed
48//! `host_ipc.Register`'s treatment, not a weaker one.
49//!
50//! The gate is applied to the whole service rather than to `Fill` alone:
51//! `ResolveIdentity` leaks identity_ids, `Lookup` confirms which sites have
52//! saved credentials, and `Save`/`Delete` let an agent plant or destroy
53//! them. None of those are things an agent should reach either, and a
54//! uniform gate cannot be defeated by finding the one method someone forgot
55//! to cover.
56//!
57//! Note this proves *caller is the host*, not *a human approved this exact
58//! use*. That is the boundary the threat model needs — the host is the
59//! process that runs the approval window, so a capability token it mints
60//! for itself would add no defence against the attacker in question (an
61//! agent), only an internal consistency check within the host.
62
63use serde::Deserialize;
64
65use crate::backend::service::{get_arg, WebCallType, WebReturnType};
66use crate::identity::browser_credential_store;
67
68use super::super::AppState;
69use super::host_ipc::secret_matches;
70
71pub(super) async fn handle_credential_service(state: &AppState, call: &WebCallType) -> WebReturnType {
72 if let Some(rejection) = reject_unless_host_caller(state, call) {
73 return rejection;
74 }
75 match call.method.as_str() {
76 "ResolveIdentity" => handle_resolve_identity(state, call),
77 "Lookup" => handle_lookup(call),
78 "Save" => handle_save(call),
79 "Delete" => handle_delete(call),
80 "Fill" => handle_fill(call),
81 _ => WebReturnType::error(format!("unknown credential method: {}", call.method)),
82 }
83}
84
85/// `Some(error)` when the caller failed to prove it's the paired host —
86/// checked for EVERY method before dispatch, so a new method added later is
87/// gated by construction rather than by remembering to gate it.
88///
89/// Fails closed when srv has no secret configured: with nothing to verify
90/// against, "allow" would silently restore the exact bypass this exists to
91/// close. Mirrors `host_ipc::handle_register`'s `None` arm.
92/// The whole security decision, as a pure function of the two secrets —
93/// extracted so it can be tested directly (constructing an `AppState` in a
94/// unit test isn't practical, and this gate is far too load-bearing to
95/// leave to integration coverage alone).
96///
97/// `known == None` → always `false`: fail closed. See the caller.
98///
99/// An EMPTY configured secret is treated the same as an absent one. Without
100/// that, `secret_matches("", "")` compares equal and a caller supplying
101/// nothing — precisely what an agent without the secret sends — would be
102/// admitted, turning the misconfiguration into a skeleton key. `Config`
103/// already `.filter(|s| !s.is_empty())`s this value (`host_reg_secret` in
104/// `config.rs`'s `Config::from_env_and_args`), so
105/// today the case is unreachable; the check is here so the gate stays
106/// correct on its own terms rather than depending on a filter two modules
107/// away that a future refactor could drop.
108pub(super) fn host_caller_allowed(known: Option<&str>, supplied: &str) -> bool {
109 match known {
110 None => false,
111 Some(known) if known.is_empty() => false,
112 Some(known) => secret_matches(known, supplied),
113 }
114}
115
116fn reject_unless_host_caller(state: &AppState, call: &WebCallType) -> Option<WebReturnType> {
117 let supplied = call.args.get(1).and_then(|v| v.as_str()).unwrap_or("");
118 if host_caller_allowed(state.host_reg_secret.as_deref(), supplied) {
119 return None;
120 }
121 match state.host_reg_secret.as_deref() {
122 None => {
123 tracing::error!(
124 "[credential] REJECTED a {} call — this srv instance has no \
125 AGENTMUX_HOST_REG_SECRET configured, so it cannot verify the caller is \
126 really the paired host. Refusing rather than falling back to the shared \
127 X-AuthKey, which every agent can read from its own environment.",
128 call.method
129 );
130 Some(WebReturnType::error(
131 "credential: srv has no host-registration secret configured — refusing",
132 ))
133 }
134 Some(_) => {
135 tracing::error!(
136 "[credential] REJECTED a {} call — args[1] did not match this srv \
137 instance's AGENTMUX_HOST_REG_SECRET. Either a caller without that \
138 credential (an agent riding the shared X-AuthKey never has it) is \
139 attempting to read stored browser passwords, or a genuine host is out \
140 of sync with srv's current secret (e.g. after a srv recycle).",
141 call.method
142 );
143 Some(WebReturnType::error(
144 "credential: args[1] does not match this srv instance's host-registration secret",
145 ))
146 }
147 }
148}
149
150#[derive(Deserialize)]
151struct ResolveIdentityArgs {
152 block_id: String,
153}
154
155/// `block_id` → `identity_id`, via the same instance-lookup the identity
156/// resolver already uses for OAuth env-var injection at spawn time
157/// (`identity::resolver::inject::…`) — reusing
158/// `Store::instance_get_active_for_block` here keeps "which identity owns
159/// this pane" resolved in exactly one place in the codebase.
160///
161/// Returns `identity_id: ""` (not an error) when the block has no active
162/// instance or the instance has no identity bound — the caller (the CEF
163/// host's credential broker) treats an empty identity_id the same as "no
164/// stored credential" and falls through to the normal prompt, matching
165/// how identity injection itself already treats an unbound instance as a
166/// no-op rather than a hard failure.
167fn handle_resolve_identity(state: &AppState, call: &WebCallType) -> WebReturnType {
168 let args: ResolveIdentityArgs = match get_arg(&call.args, 0) {
169 Ok(a) => a,
170 Err(e) => return WebReturnType::error(format!("credential.ResolveIdentity: {e}")),
171 };
172 let identity_id = state
173 .mstore
174 .instance_get_active_for_block(&args.block_id)
175 .ok()
176 .flatten()
177 .map(|inst| inst.identity_id)
178 .unwrap_or_default();
179 WebReturnType::success(serde_json::json!({ "identity_id": identity_id }))
180}
181
182#[derive(Deserialize)]
183struct ProtectionSpaceArgs {
184 identity_id: String,
185 origin: String,
186 realm: String,
187 is_proxy: bool,
188}
189
190fn handle_lookup(call: &WebCallType) -> WebReturnType {
191 let args: ProtectionSpaceArgs = match get_arg(&call.args, 0) {
192 Ok(a) => a,
193 Err(e) => return WebReturnType::error(format!("credential.Lookup: {e}")),
194 };
195 match browser_credential_store::load(&args.identity_id, &args.origin, &args.realm, args.is_proxy) {
196 Ok(Some(cred)) => WebReturnType::success(serde_json::json!({
197 "found": true,
198 "username": mask_username(&cred.username),
199 })),
200 Ok(None) => WebReturnType::success(serde_json::json!({ "found": false })),
201 Err(e) => {
202 // Never surface the raw keychain error to the caller beyond a
203 // generic message — the exact failure mode (locked keychain,
204 // no Secret Service daemon, permission denied) is diagnostic
205 // detail for the host's own logs, not something to round-trip
206 // back over HTTP. The host's credential_broker treats any
207 // error here identically to "not found" (fall through to the
208 // normal prompt), so the distinction wouldn't be actionable
209 // on that side anyway.
210 tracing::warn!("[credential] Lookup failed: {e}");
211 WebReturnType::error("lookup failed")
212 }
213 }
214}
215
216/// `us***@example.com` / `ab***` style masking for the approval window's
217/// display — enough for a human to recognize which saved login is being
218/// offered, not enough to be useful if somehow logged or screenshotted
219/// wrong. Keeps at most the first 2 characters before any masking.
220fn mask_username(username: &str) -> String {
221 let visible: String = username.chars().take(2).collect();
222 if username.chars().count() <= 2 {
223 return "*".repeat(username.chars().count().max(1));
224 }
225 format!("{visible}***")
226}
227
228#[derive(Deserialize)]
229struct SaveArgs {
230 identity_id: String,
231 origin: String,
232 realm: String,
233 is_proxy: bool,
234 username: String,
235 password: String,
236}
237
238fn handle_save(call: &WebCallType) -> WebReturnType {
239 let args: SaveArgs = match get_arg(&call.args, 0) {
240 Ok(a) => a,
241 Err(e) => return WebReturnType::error(format!("credential.Save: {e}")),
242 };
243 match browser_credential_store::save(
244 &args.identity_id,
245 &args.origin,
246 &args.realm,
247 args.is_proxy,
248 &args.username,
249 &args.password,
250 ) {
251 Ok(()) => WebReturnType::success_empty(),
252 Err(e) => {
253 tracing::warn!("[credential] Save failed: {e}");
254 WebReturnType::error("save failed")
255 }
256 }
257}
258
259fn handle_delete(call: &WebCallType) -> WebReturnType {
260 let args: ProtectionSpaceArgs = match get_arg(&call.args, 0) {
261 Ok(a) => a,
262 Err(e) => return WebReturnType::error(format!("credential.Delete: {e}")),
263 };
264 match browser_credential_store::delete(&args.identity_id, &args.origin, &args.realm, args.is_proxy) {
265 Ok(()) => WebReturnType::success_empty(),
266 Err(e) => {
267 tracing::warn!("[credential] Delete failed: {e}");
268 WebReturnType::error("delete failed")
269 }
270 }
271}
272
273/// The one method that returns a real, unmasked password. Called exactly
274/// once per approved credential use — see this module's own doc comment.
275fn handle_fill(call: &WebCallType) -> WebReturnType {
276 let args: ProtectionSpaceArgs = match get_arg(&call.args, 0) {
277 Ok(a) => a,
278 Err(e) => return WebReturnType::error(format!("credential.Fill: {e}")),
279 };
280 match browser_credential_store::load(&args.identity_id, &args.origin, &args.realm, args.is_proxy) {
281 Ok(Some(cred)) => WebReturnType::success(serde_json::json!({
282 "username": cred.username,
283 "password": cred.password,
284 })),
285 Ok(None) => WebReturnType::error("no stored credential for this identity/origin/realm"),
286 Err(e) => {
287 tracing::warn!("[credential] Fill failed: {e}");
288 WebReturnType::error("fill failed")
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn mask_username_keeps_at_most_two_leading_chars() {
299 assert_eq!(mask_username("alice"), "al***");
300 assert_eq!(mask_username("bob"), "bo***");
301 }
302
303 #[test]
304 fn mask_username_handles_very_short_names() {
305 assert_eq!(mask_username("a"), "*");
306 assert_eq!(mask_username("ab"), "**");
307 }
308
309 #[test]
310 fn mask_username_handles_empty() {
311 assert_eq!(mask_username(""), "*");
312 }
313
314 // ── Host-caller gate (reagent P0 on PR #2824) ────────────────────────
315 //
316 // The attack these close: an agent reads AGENTMUX_AUTH_KEY and
317 // AGENTMUX_BLOCKID from its own environment (both injected at spawn) and
318 // curls credential.Fill to get a stored plaintext password with no human
319 // approval anywhere in the path. The only thing standing between an agent
320 // and that password is this gate, so it gets tested directly.
321
322 const KNOWN: &str = "host-registration-secret-value";
323
324 #[test]
325 fn the_real_host_secret_is_accepted() {
326 assert!(host_caller_allowed(Some(KNOWN), KNOWN));
327 }
328
329 /// The exact shape of the bypass: an agent has `X-AuthKey` (which got it
330 /// this far) but cannot produce `AGENTMUX_HOST_REG_SECRET`, so it sends
331 /// nothing for `args[1]` and `unwrap_or("")` yields an empty string.
332 #[test]
333 fn an_absent_secret_is_rejected() {
334 assert!(!host_caller_allowed(Some(KNOWN), ""));
335 }
336
337 #[test]
338 fn a_wrong_secret_is_rejected() {
339 assert!(!host_caller_allowed(Some(KNOWN), "not-the-secret"));
340 assert!(!host_caller_allowed(Some(KNOWN), "host-registration-secret-valu"));
341 assert!(!host_caller_allowed(Some(KNOWN), "host-registration-secret-value-extra"));
342 }
343
344 /// Fails CLOSED. If srv has no secret configured there is nothing to
345 /// verify against, and treating that as "allow" would silently restore
346 /// the full bypass on exactly the misconfiguration least likely to be
347 /// noticed. Mirrors `host_ipc::handle_register`'s `None` arm.
348 #[test]
349 fn no_configured_secret_rejects_everything_rather_than_allowing_it() {
350 assert!(!host_caller_allowed(None, ""));
351 assert!(!host_caller_allowed(None, KNOWN));
352 assert!(!host_caller_allowed(None, "anything at all"));
353 }
354
355 /// An empty configured secret must not become a skeleton key for callers
356 /// that also send nothing — `Some("")` is a misconfiguration, not a
357 /// credential, and the empty-vs-empty comparison would otherwise pass.
358 #[test]
359 fn an_empty_configured_secret_does_not_admit_an_empty_supplied_secret() {
360 assert!(!host_caller_allowed(Some(""), ""));
361 }
362}