Skip to main content

agentmux_srv/backend/storage/
agent_lan_keys.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent Ed25519 keypair for LAN-tier jekt sender verification.
5//! See db_agent_lan_keys in migrations.rs (OBJECT_SCHEMA_VERSION v19) and
6//! docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.1.
7//!
8//! Mirrors agent_jekt_keys.rs exactly, asymmetric instead of symmetric: one
9//! row per locally-registered agent_id, minted once (first call to
10//! `agent_lan_key_ensure`) and never leaves this srv instance except by the
11//! PRIVATE half being injected into that ONE agent's own MCP server process
12//! env (`AGENTMUX_LAN_KEY`) — never into any other agent's env, and never
13//! returned over any RPC. The public half is not secret — it's what gets
14//! handed to LAN peers on request (see `LanDiscovery::find_agent_lan_pubkey`)
15//! so they can verify this agent's outgoing signatures.
16
17use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
18use rusqlite::params;
19
20use super::error::StoreError;
21use super::store::Store;
22
23use agentmux_common::time::now_secs;
24
25/// 32 bytes of randomness via two v4 UUIDs, for every key table in this
26/// module (LAN, WAN, jekt, WAN identity) and the agent tokens. Avoids adding a `rand`/`getrandom`
27/// dependency: `uuid`'s v4 generation is already CSPRNG-backed, and
28/// `ed25519_dalek::SigningKey::from_bytes` accepts any 32 bytes of
29/// randomness as a valid seed (deterministic derivation from the seed, not
30/// a call into an RNG itself).
31pub(super) fn random_seed_bytes() -> [u8; 32] {
32    let mut bytes = [0u8; 32];
33    bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
34    bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
35    bytes
36}
37
38/// An agent's LAN signing keypair, both halves base64-encoded for
39/// storage/transport.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct AgentLanKeypair {
42    pub public_key: String,
43    pub private_key: String,
44}
45
46impl Store {
47    /// Load this agent's LAN keypair if one has already been minted,
48    /// without creating one.
49    pub fn agent_lan_key_load(&self, agent_id: &str) -> Result<Option<AgentLanKeypair>, StoreError> {
50        let key = agent_id.to_lowercase();
51        let conn = self.conn.lock().unwrap();
52        let mut stmt =
53            conn.prepare("SELECT public_key, private_key FROM db_agent_lan_keys WHERE agent_id = ?1")?;
54        match stmt.query_row(params![key], |row| {
55            Ok(AgentLanKeypair {
56                public_key: row.get(0)?,
57                private_key: row.get(1)?,
58            })
59        }) {
60            Ok(v) => Ok(Some(v)),
61            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
62            Err(e) => Err(e.into()),
63        }
64    }
65
66    /// Load only this agent's PUBLIC key — the half safe to hand to a LAN
67    /// peer's pubkey-lookup query (see `LanDiscovery::find_agent_lan_pubkey`
68    /// and the `GET /agentmux/reactive/agent` handler it calls). Never loads
69    /// or touches the private half.
70    pub fn agent_lan_public_key_load(&self, agent_id: &str) -> Result<Option<String>, StoreError> {
71        let key = agent_id.to_lowercase();
72        let conn = self.conn.lock().unwrap();
73        let mut stmt = conn.prepare("SELECT public_key FROM db_agent_lan_keys WHERE agent_id = ?1")?;
74        match stmt.query_row(params![key], |row| row.get(0)) {
75            Ok(v) => Ok(Some(v)),
76            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
77            Err(e) => Err(e.into()),
78        }
79    }
80
81    /// Return this agent's LAN keypair, minting and persisting a fresh
82    /// random one on first use. Race-safe under concurrent first-use — same
83    /// `INSERT OR IGNORE` + re-read pattern as `agent_jekt_key_ensure`: two
84    /// concurrent callers for the same never-before-seen agent_id agree on
85    /// one keypair instead of each minting and using a different one.
86    pub fn agent_lan_key_ensure(&self, agent_id: &str) -> Result<AgentLanKeypair, StoreError> {
87        let key = agent_id.to_lowercase();
88        if let Some(existing) = self.agent_lan_key_load(&key)? {
89            return Ok(existing);
90        }
91        let conn = self.conn.lock().unwrap();
92        let seed = random_seed_bytes();
93        let (public_bytes, private_bytes) = agentmux_common::jekt_sign::generate_lan_keypair(seed);
94        let public_key = BASE64.encode(public_bytes);
95        let private_key = BASE64.encode(private_bytes);
96        conn.execute(
97            "INSERT OR IGNORE INTO db_agent_lan_keys (agent_id, public_key, private_key, created_at) \
98             VALUES (?1, ?2, ?3, ?4)",
99            params![key, public_key, private_key, now_secs()],
100        )?;
101        // Re-read regardless of whether our insert won the race — single
102        // source of truth for "what keypair does this agent have," not an
103        // assumption that our own insert succeeded.
104        let mut stmt =
105            conn.prepare("SELECT public_key, private_key FROM db_agent_lan_keys WHERE agent_id = ?1")?;
106        stmt.query_row(params![key], |row| {
107            Ok(AgentLanKeypair {
108                public_key: row.get(0)?,
109                private_key: row.get(1)?,
110            })
111        })
112        .map_err(Into::into)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn object_store() -> Store {
121        Store::open(std::path::Path::new(":memory:")).unwrap()
122    }
123
124    #[test]
125    fn load_returns_none_when_unminted() {
126        let store = object_store();
127        assert!(store.agent_lan_key_load("agentx").unwrap().is_none());
128        assert!(store.agent_lan_public_key_load("agentx").unwrap().is_none());
129    }
130
131    #[test]
132    fn ensure_mints_a_valid_base64_keypair() {
133        let store = object_store();
134        let keypair = store.agent_lan_key_ensure("agentx").unwrap();
135        let pub_bytes = BASE64.decode(&keypair.public_key).unwrap();
136        let priv_bytes = BASE64.decode(&keypair.private_key).unwrap();
137        assert_eq!(pub_bytes.len(), 32);
138        assert_eq!(priv_bytes.len(), 32);
139        assert_ne!(keypair.public_key, keypair.private_key);
140    }
141
142    #[test]
143    fn ensure_is_idempotent_same_agent_gets_same_keypair() {
144        let store = object_store();
145        let first = store.agent_lan_key_ensure("AgentX").unwrap();
146        let second = store.agent_lan_key_ensure("agentx").unwrap(); // case-insensitive
147        assert_eq!(first, second);
148    }
149
150    #[test]
151    fn different_agents_get_different_keypairs() {
152        let store = object_store();
153        let a = store.agent_lan_key_ensure("agentx").unwrap();
154        let b = store.agent_lan_key_ensure("agenty").unwrap();
155        assert_ne!(a.public_key, b.public_key);
156        assert_ne!(a.private_key, b.private_key);
157    }
158
159    #[test]
160    fn ensure_then_load_round_trips() {
161        let store = object_store();
162        let minted = store.agent_lan_key_ensure("agentx").unwrap();
163        let loaded = store.agent_lan_key_load("agentx").unwrap().unwrap();
164        assert_eq!(minted, loaded);
165    }
166
167    #[test]
168    fn public_key_load_matches_the_public_half_of_the_full_keypair() {
169        let store = object_store();
170        let minted = store.agent_lan_key_ensure("agentx").unwrap();
171        let public_only = store.agent_lan_public_key_load("agentx").unwrap().unwrap();
172        assert_eq!(minted.public_key, public_only);
173    }
174
175    #[test]
176    fn a_minted_keypair_can_sign_and_verify_a_real_jekt() {
177        // End-to-end sanity through the actual jekt_sign functions, not just
178        // storage round-tripping — proves the stored encoding is exactly
179        // what generate_lan_keypair/sign_lan_jekt/verify_lan_jekt expect.
180        let store = object_store();
181        let keypair = store.agent_lan_key_ensure("agentx").unwrap();
182        let private = BASE64.decode(&keypair.private_key).unwrap();
183        let public = BASE64.decode(&keypair.public_key).unwrap();
184        let sig = agentmux_common::jekt_sign::sign_lan_jekt(
185            &private, "msg-1", "agentx", "agenty", 1_000, "hello",
186        )
187        .unwrap();
188        assert!(agentmux_common::jekt_sign::verify_lan_jekt(
189            &public, "msg-1", "agentx", "agenty", 1_000, "hello", &sig,
190        ));
191    }
192}