Skip to main content

agentmux_srv/backend/storage/
agent_jekt_keys.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent HMAC-SHA256 signing key for host-tier jekt sender verification.
5//! See db_agent_jekt_keys in migrations.rs (OBJECT_SCHEMA_VERSION v18) and
6//! docs/specs/SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2.
7//!
8//! One row per locally-registered agent_id. The key is minted once (first
9//! call to `agent_jekt_key_ensure` for that agent — normally at spawn, via
10//! `agent_config`/`agent_open`) and never leaves this srv instance except by
11//! being injected into that ONE agent's own MCP server process env
12//! (`AGENTMUX_JEKT_KEY`) — never into any other agent's env, and never
13//! returned over any RPC. This is what makes the resulting signature mean
14//! something: only the agent it claims to be from ever held the key needed
15//! to produce it.
16//!
17//! As of docs/specs/SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md, a key
18//! past `JEKT_KEY_TTL_SECS` old is rotated the next time `agent_jekt_key_ensure`
19//! is called for it (i.e. at that agent's next spawn) — see that spec for
20//! why this is lazy-at-next-mint rather than invalidating a live key.
21//! `agent_jekt_key_load` (the verification-read path) is deliberately
22//! unaffected: it has no concept of "too old," only "matches" or "doesn't."
23
24use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
25use rusqlite::params;
26
27use super::error::StoreError;
28use super::store::Store;
29use super::agent_lan_keys::random_seed_bytes;
30
31use agentmux_common::time::now_secs;
32
33/// A key older than this is rotated at its agent's next spawn (the next
34/// `agent_jekt_key_ensure` call), not invalidated in place — see
35/// docs/specs/SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md §2.
36pub(crate) const JEKT_KEY_TTL_SECS: i64 = 24 * 60 * 60;
37
38impl Store {
39    /// Load this agent's signing key if one has already been minted, without
40    /// creating one. Returns raw key bytes (decoded from the stored base64).
41    pub fn agent_jekt_key_load(&self, agent_id: &str) -> Result<Option<Vec<u8>>, StoreError> {
42        let key = agent_id.to_lowercase();
43        let conn = self.conn.lock().unwrap();
44        let mut stmt = conn.prepare("SELECT hmac_key FROM db_agent_jekt_keys WHERE agent_id = ?1")?;
45        let encoded: Option<String> = match stmt.query_row(params![key], |row| row.get(0)) {
46            Ok(v) => Some(v),
47            Err(rusqlite::Error::QueryReturnedNoRows) => None,
48            Err(e) => return Err(e.into()),
49        };
50        match encoded {
51            None => Ok(None),
52            Some(encoded) => BASE64
53                .decode(&encoded)
54                .map(Some)
55                .map_err(|e| StoreError::Other(format!("agent_jekt_key: stored key is not valid base64: {e}"))),
56        }
57    }
58
59    /// When this agent's jekt key expires (unix seconds): its creation plus
60    /// [`JEKT_KEY_TTL_SECS`]. Until then no `ensure` rotates it, so a holder
61    /// of the key needs nothing newer before this time (identity M4d-3).
62    pub fn agent_jekt_key_expires_at(&self, agent_id: &str) -> Result<Option<i64>, StoreError> {
63        Ok(self.agent_jekt_key_load_with_created_at(&agent_id.to_lowercase())?.map(|(_, created)| created + JEKT_KEY_TTL_SECS))
64    }
65
66    /// Load this agent's signing key together with its mint/last-rotation
67    /// time, if one has already been minted.
68    fn agent_jekt_key_load_with_created_at(&self, agent_id: &str) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
69        let conn = self.conn.lock().unwrap();
70        let mut stmt =
71            conn.prepare("SELECT hmac_key, created_at FROM db_agent_jekt_keys WHERE agent_id = ?1")?;
72        match stmt.query_row(params![agent_id], |row| {
73            let encoded: String = row.get(0)?;
74            let created_at: i64 = row.get(1)?;
75            Ok((encoded, created_at))
76        }) {
77            Ok((encoded, created_at)) => {
78                let bytes = BASE64.decode(&encoded).map_err(|e| {
79                    StoreError::Other(format!("agent_jekt_key: stored key is not valid base64: {e}"))
80                })?;
81                Ok(Some((bytes, created_at)))
82            }
83            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
84            Err(e) => Err(e.into()),
85        }
86    }
87
88    /// Return this agent's signing key, minting and persisting a fresh
89    /// random one on first use. Race-safe under concurrent first-use: the
90    /// insert is `OR IGNORE`, and the winning row (whichever call actually
91    /// landed first) is always what gets read back and returned, so two
92    /// concurrent callers for the same never-before-seen agent_id agree on
93    /// one key instead of each minting and using a different one.
94    ///
95    /// A key older than `JEKT_KEY_TTL_SECS` is rotated here rather than
96    /// returned as-is — see docs/specs/SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md.
97    pub fn agent_jekt_key_ensure(&self, agent_id: &str) -> Result<Vec<u8>, StoreError> {
98        let key = agent_id.to_lowercase();
99        if let Some((existing, created_at)) = self.agent_jekt_key_load_with_created_at(&key)? {
100            if now_secs() - created_at < JEKT_KEY_TTL_SECS {
101                return Ok(existing);
102            }
103            let conn = self.conn.lock().unwrap();
104            let fresh = random_seed_bytes();
105            let encoded = BASE64.encode(fresh);
106            // Guarded on the stale created_at we just read so two
107            // concurrent rotators for the same overdue agent_id agree on
108            // one winner instead of each minting a different key — same
109            // "re-read regardless of whether our own write won" shape as
110            // the first-mint path below.
111            conn.execute(
112                "UPDATE db_agent_jekt_keys SET hmac_key = ?1, created_at = ?2 \
113                 WHERE agent_id = ?3 AND created_at = ?4",
114                params![encoded, now_secs(), key, created_at],
115            )?;
116            let mut stmt = conn.prepare("SELECT hmac_key FROM db_agent_jekt_keys WHERE agent_id = ?1")?;
117            let winning: String = stmt.query_row(params![key], |row| row.get(0))?;
118            return BASE64
119                .decode(&winning)
120                .map_err(|e| StoreError::Other(format!("agent_jekt_key: stored key is not valid base64: {e}")));
121        }
122        let conn = self.conn.lock().unwrap();
123        let fresh = random_seed_bytes();
124        let encoded = BASE64.encode(fresh);
125        conn.execute(
126            "INSERT OR IGNORE INTO db_agent_jekt_keys (agent_id, hmac_key, created_at) VALUES (?1, ?2, ?3)",
127            params![key, encoded, now_secs()],
128        )?;
129        // Re-read regardless of whether our insert won the race — this is
130        // the single source of truth for "what key does this agent have,"
131        // not an assumption that our own insert succeeded.
132        let mut stmt = conn.prepare("SELECT hmac_key FROM db_agent_jekt_keys WHERE agent_id = ?1")?;
133        let winning: String = stmt.query_row(params![key], |row| row.get(0))?;
134        BASE64
135            .decode(&winning)
136            .map_err(|e| StoreError::Other(format!("agent_jekt_key: stored key is not valid base64: {e}")))
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn object_store() -> Store {
145        Store::open(std::path::Path::new(":memory:")).unwrap()
146    }
147
148    #[test]
149    fn load_returns_none_when_unminted() {
150        let store = object_store();
151        assert!(store.agent_jekt_key_load("agentx").unwrap().is_none());
152    }
153
154    #[test]
155    fn ensure_mints_a_32_byte_key() {
156        let store = object_store();
157        let key = store.agent_jekt_key_ensure("agentx").unwrap();
158        assert_eq!(key.len(), 32);
159    }
160
161    #[test]
162    fn ensure_is_idempotent_same_agent_gets_same_key() {
163        let store = object_store();
164        let first = store.agent_jekt_key_ensure("AgentX").unwrap();
165        let second = store.agent_jekt_key_ensure("agentx").unwrap(); // case-insensitive
166        assert_eq!(first, second);
167    }
168
169    #[test]
170    fn different_agents_get_different_keys() {
171        let store = object_store();
172        let a = store.agent_jekt_key_ensure("agentx").unwrap();
173        let b = store.agent_jekt_key_ensure("agenty").unwrap();
174        assert_ne!(a, b);
175    }
176
177    #[test]
178    fn ensure_then_load_round_trips() {
179        let store = object_store();
180        let minted = store.agent_jekt_key_ensure("agentx").unwrap();
181        let loaded = store.agent_jekt_key_load("agentx").unwrap().unwrap();
182        assert_eq!(minted, loaded);
183    }
184
185    /// Backdates the stored `created_at` for `agent_id` directly via SQL —
186    /// simulates "this key was minted a while ago" without sleeping in a
187    /// test.
188    fn backdate(store: &Store, agent_id: &str, age_secs: i64) {
189        let conn = store.conn.lock().unwrap();
190        conn.execute(
191            "UPDATE db_agent_jekt_keys SET created_at = ?1 WHERE agent_id = ?2",
192            params![now_secs() - age_secs, agent_id],
193        )
194        .unwrap();
195    }
196
197    #[test]
198    fn ensure_does_not_rotate_before_ttl_expiry() {
199        let store = object_store();
200        let first = store.agent_jekt_key_ensure("agentx").unwrap();
201        backdate(&store, "agentx", JEKT_KEY_TTL_SECS - 60);
202        let second = store.agent_jekt_key_ensure("agentx").unwrap();
203        assert_eq!(first, second, "a key under the TTL must be returned unchanged");
204    }
205
206    #[test]
207    fn ensure_rotates_key_after_ttl_expiry() {
208        let store = object_store();
209        let first = store.agent_jekt_key_ensure("agentx").unwrap();
210        backdate(&store, "agentx", JEKT_KEY_TTL_SECS + 60);
211        let second = store.agent_jekt_key_ensure("agentx").unwrap();
212        assert_ne!(first, second, "a key past the TTL must be rotated to a fresh one");
213        assert_eq!(second.len(), 32);
214    }
215
216    #[test]
217    fn rotation_is_reflected_by_load_not_just_ensure() {
218        let store = object_store();
219        let first = store.agent_jekt_key_ensure("agentx").unwrap();
220        backdate(&store, "agentx", JEKT_KEY_TTL_SECS + 60);
221        let rotated = store.agent_jekt_key_ensure("agentx").unwrap();
222        let loaded = store.agent_jekt_key_load("agentx").unwrap().unwrap();
223        assert_eq!(loaded, rotated);
224        assert_ne!(loaded, first, "load must see the rotated key, not the original");
225    }
226
227    #[test]
228    fn other_agents_keys_are_unaffected_by_one_agents_rotation() {
229        let store = object_store();
230        let a_before = store.agent_jekt_key_ensure("agentx").unwrap();
231        let b = store.agent_jekt_key_ensure("agenty").unwrap();
232        backdate(&store, "agentx", JEKT_KEY_TTL_SECS + 60);
233        let a_after = store.agent_jekt_key_ensure("agentx").unwrap();
234        assert_ne!(a_before, a_after);
235        assert_eq!(b, store.agent_jekt_key_load("agenty").unwrap().unwrap());
236    }
237}