Skip to main content

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}