agentmux_srv/backend/storage/
agent_jekt_keys.rs1use 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
33pub(crate) const JEKT_KEY_TTL_SECS: i64 = 24 * 60 * 60;
37
38impl Store {
39 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 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 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 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 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 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(); 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 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}