Skip to main content

agentmux_srv/server/
agent_self_keys.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `GET /agentmux/agents/self/keys` — identity M4d-3
5//! (`SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` §6.5.10).
6//!
7//! An agent's MCP fetches its own signing keys here instead of reading them
8//! from `.mcp.json`, which goes stale: the host jekt key rotates after 24 h
9//! (`JEKT_KEY_TTL_SECS`) while a running agent keeps the copy it was launched
10//! with. Served only to `Caller::Agent(uid)`, the agent its `X-Agent-Token`
11//! names: these are private keys, so the 401 is this endpoint's own.
12//!
13//! The name-keyed jekt, LAN and WAN keys are those of the row's slug only,
14//! ensured as injection ensures them (so the jekt key rotates here), and the
15//! response says which slug they belong to: the MCP uses them only when that
16//! slug is exactly its own `AGENTMUX_AGENT_ID`. The UID-keyed LAN and WAN keys
17//! (M4d-2) ride along for M4d-6's v2 signatures. Nothing here is logged.
18
19use axum::extract::State;
20use axum::http::{header, StatusCode};
21use axum::response::{IntoResponse, Response};
22use axum::Json;
23use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
24use serde::Serialize;
25
26use super::caller::Caller;
27use super::AppState;
28
29#[derive(Serialize, Debug, PartialEq, Eq)]
30pub(crate) struct SelfKeys {
31    /// The row's slug: the only name these name-keyed keys are for.
32    pub slug: String,
33    /// Host-tier HMAC key, base64.
34    pub jekt_key: Option<String>,
35    /// When `jekt_key` expires (unix seconds). srv rotates a key only once it
36    /// has, so the MCP re-fetches at this time, not on a clock of its own.
37    pub jekt_key_expires_at: Option<i64>,
38    /// LAN (and cross-channel) Ed25519 private key, base64.
39    pub lan_key: Option<String>,
40    /// WAN Ed25519 private key, base64.
41    pub wan_key: Option<String>,
42    pub uid: String,
43    /// The UID-keyed LAN and WAN private keys (M4d-2), base64.
44    pub uid_lan_key: Option<String>,
45    pub uid_wan_key: Option<String>,
46}
47
48/// The keys for `uid`, or `None` when it has no row.
49pub(crate) fn self_keys_for(mstore: &crate::backend::storage::store::Store, uid: &str) -> Option<SelfKeys> {
50    let def = mstore.agent_def_get(uid).ok().flatten()?;
51    let slug = def.slug.trim().to_string();
52    let named = !slug.is_empty();
53    let jekt_key = named.then(|| mstore.agent_jekt_key_ensure(&slug).ok()).flatten().map(|k| BASE64.encode(k));
54    let jekt_key_expires_at = named.then(|| mstore.agent_jekt_key_expires_at(&slug).ok().flatten()).flatten();
55    let lan_key = named.then(|| mstore.agent_lan_key_ensure(&slug).ok()).flatten().map(|k| k.private_key);
56    let wan_key = named.then(|| mstore.agent_wan_key_ensure(&slug).ok()).flatten().map(|k| k.private_key);
57    let uid_keys = mstore.agent_uid_keys_ensure(uid).ok().flatten().map(|(keys, _)| keys);
58    Some(SelfKeys {
59        slug,
60        jekt_key,
61        jekt_key_expires_at,
62        lan_key,
63        wan_key,
64        uid: uid.to_string(),
65        uid_lan_key: uid_keys.as_ref().map(|k| k.lan.private_key.clone()),
66        uid_wan_key: uid_keys.map(|k| k.wan.private_key),
67    })
68}
69
70pub(crate) async fn handle_agent_self_keys(State(state): State<AppState>, caller: Option<axum::Extension<Caller>>) -> Response {
71    let Some(uid) = caller.as_deref().and_then(Caller::uid).map(str::to_string) else {
72        return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "agents/self/keys: this request carries no agent identity (X-Agent-Token)" }))).into_response();
73    };
74    let mstore = state.mstore.clone();
75    let keys = tokio::task::spawn_blocking(move || self_keys_for(&mstore, &uid)).await.ok().flatten();
76    match keys {
77        Some(keys) => ([(header::CACHE_CONTROL, "no-store")], Json(keys)).into_response(),
78        None => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "agents/self/keys: no agent row for this token" }))).into_response(),
79    }
80}
81
82/// Identity M4d-3's own gate signal (spec §6.5.10, "Gating per step"): a
83/// config written with an `AGENTMUX_AGENT_ID` that is not exactly its row's
84/// slug. Such an agent signs under a name its row doesn't have (a
85/// collision-suffixed backfill, a #3573 stub), so the keys
86/// `/agentmux/agents/self/keys` serves are not its keys, and its config must
87/// keep carrying them. Counting only: nothing here changes what is written.
88/// `row_slug` is `None` when no single row could be matched.
89pub(crate) fn record_config_id_drift(site: &'static str, written_id: &str, row_slug: Option<&str>) {
90    if row_slug.map(str::trim) != Some(written_id) {
91        crate::backend::agent_resolve::record_uid_fallback(site);
92    }
93}
94
95/// The one row whose slug equals `name` folded (trimmed, case-insensitive),
96/// or `None` when none or several do. `WriteAgentConfig` has no `Caller` and
97/// names its agent only by the id in the content.
98pub(crate) fn single_row_slug_folded(mstore: &crate::backend::storage::store::Store, name: &str) -> Option<String> {
99    let mut slugs = mstore.agent_slugs_folded(name).ok()?;
100    (slugs.len() == 1).then(|| slugs.remove(0))
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::backend::storage::agents::test_agent_def;
107    use crate::backend::storage::store::Store;
108
109    fn store_with(uid: &str, slug: &str) -> Store {
110        let store = Store::open_in_memory().unwrap();
111        let mut def = test_agent_def(uid, slug, "claude", "agent", agentmux_common::time::now_ms(), "");
112        def.slug = slug.to_string();
113        store.agent_def_insert(&mut def).unwrap();
114        store
115    }
116
117    #[test]
118    fn serves_the_slug_s_keys_the_same_ones_injection_writes() {
119        let store = store_with("uid-aria", "aria");
120        let keys = self_keys_for(&store, "uid-aria").unwrap();
121        assert_eq!(keys.slug, "aria");
122        assert_eq!(keys.jekt_key, Some(BASE64.encode(store.agent_jekt_key_ensure("aria").unwrap())));
123        assert_eq!(keys.lan_key, Some(store.agent_lan_key_ensure("aria").unwrap().private_key));
124        assert_eq!(keys.wan_key, Some(store.agent_wan_key_ensure("aria").unwrap().private_key));
125        assert!(keys.uid_lan_key.is_some() && keys.uid_wan_key.is_some());
126        let expires = keys.jekt_key_expires_at.expect("an expiry with the key");
127        let ttl = crate::backend::storage::agent_jekt_keys::JEKT_KEY_TTL_SECS;
128        assert!((expires - agentmux_common::time::now_secs() - ttl).abs() <= 5, "a fresh key expires a TTL from now");
129        assert_eq!(self_keys_for(&store, "uid-aria").unwrap(), keys, "stable across fetches");
130    }
131
132    #[test]
133    fn an_unknown_uid_gets_nothing() {
134        let store = Store::open_in_memory().unwrap();
135        assert_eq!(self_keys_for(&store, "uid-nobody"), None);
136    }
137
138    #[test]
139    fn a_config_s_id_matches_one_row_only_when_one_slug_folds_to_it() {
140        let store = store_with("uid-a", "Aria");
141        assert_eq!(super::single_row_slug_folded(&store, "aria").as_deref(), Some("Aria"));
142        let mut def = test_agent_def("uid-b", "ä", "claude", "agent", 1, "");
143        def.slug = "Ä".into();
144        store.agent_def_insert(&mut def).unwrap();
145        let mut def = test_agent_def("uid-c", "ä", "claude", "agent", 1, "");
146        def.slug = "ä".into();
147        store.agent_def_insert(&mut def).unwrap();
148        assert_eq!(super::single_row_slug_folded(&store, "ä"), None, "Ä and ä fold together: ambiguous");
149        assert_eq!(super::single_row_slug_folded(&store, "nobody"), None);
150    }
151
152    #[test]
153    fn drift_is_counted_when_the_written_id_is_not_exactly_the_row_s_slug() {
154        let count = || {
155            crate::backend::agent_resolve::uid_fallback_counts()
156                .into_iter()
157                .find(|(site, _)| *site == "m4d.config_id_drift.test")
158                .map_or(0, |(_, n)| n)
159        };
160        let before = count();
161        super::record_config_id_drift("m4d.config_id_drift.test", "aria", Some("aria"));
162        assert_eq!(count(), before, "exact match: no drift");
163        super::record_config_id_drift("m4d.config_id_drift.test", "aria", Some("aria-2"));
164        super::record_config_id_drift("m4d.config_id_drift.test", "Aria", Some("aria"));
165        super::record_config_id_drift("m4d.config_id_drift.test", "aria", None);
166        assert_eq!(count(), before + 3);
167    }
168}