Skip to main content

agentmux_srv/backend/storage/
agent_wan_keys.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent Ed25519 keypair for general agent-to-agent WAN-tier jekt
5//! signing. See `db_agent_wan_keys` in migrations.rs (OBJECT_SCHEMA_VERSION
6//! v36).
7//!
8//! Mirrors `agent_lan_keys.rs`: one row per locally-registered agent_id,
9//! minted once (first call to `agent_wan_key_ensure`), with the PRIVATE half
10//! leaving this srv instance only by being injected into that ONE agent's own
11//! MCP server process env (`AGENTMUX_WAN_KEY`) — never into any other agent's
12//! env, never returned over any RPC. The public half is not secret: it is what
13//! gets published to muxbus's agent-ownership registry so a WAN counterpart
14//! can verify this agent's outgoing signatures.
15//!
16//! **Why this is a separate table from `db_agent_lan_keys` rather than a reuse
17//! of the same keypair.** The two tiers need different key *lifecycles*, and
18//! that difference is structural, not stylistic:
19//!
20//!  - A LAN public key is permanently trust-on-first-use pinned by every peer
21//!    that has ever seen it (`db_lan_peer_pubkey_pins`, no expiry, no re-pin
22//!    path), which is exactly why `SPEC_JEKT_HOST_KEY_TTL_ROTATION_2026_09_14.md`
23//!    §4 declined to rotate LAN keys — rotation would turn every pinned peer
24//!    into a permanent false "active forgery" alarm.
25//!  - A WAN public key is served from an authoritative registry the receiver
26//!    queries, so rotation is a republish rather than a spoofing alarm, and
27//!    the spec intends WAN keys to rotate.
28//!
29//! One keypair cannot have both lifecycles. Keeping them separate also means
30//! a compromise of one tier's key does not hand over the other's.
31//!
32//! **Scope note:** minting and signing ship ahead of verification on purpose.
33//! Verifying a WAN signature requires resolving `(sender_account,
34//! source_agent)` to exactly one key, which waits on relay-side work tracked
35//! in the private cloud repo. An agent
36//! only receives a key when it is spawned, so minting now is what makes
37//! verification meaningful later instead of applying to almost nobody.
38
39use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
40use rusqlite::params;
41
42use super::error::StoreError;
43use super::store::Store;
44
45use agentmux_common::time::now_secs;
46
47use super::agent_lan_keys::random_seed_bytes;
48
49/// An agent's WAN signing keypair, both halves base64-encoded for
50/// storage/transport, plus the version the publish/rotation phases will use.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AgentWanKeypair {
53    pub public_key: String,
54    pub private_key: String,
55    /// Monotonic version of this keypair, starting at 1. Reserved for the
56    /// version-synchronised publish (§3.2) and rotation (§3.6) phases —
57    /// nothing reads it yet, and it is stored now only so adopting those
58    /// phases needs no second schema change.
59    pub key_version: i64,
60}
61
62impl Store {
63    /// Load this agent's WAN keypair if one has already been minted, without
64    /// creating one.
65    pub fn agent_wan_key_load(&self, agent_id: &str) -> Result<Option<AgentWanKeypair>, StoreError> {
66        let key = agent_id.to_lowercase();
67        let conn = self.conn.lock().unwrap();
68        let mut stmt = conn.prepare(
69            "SELECT public_key, private_key, key_version FROM db_agent_wan_keys WHERE agent_id = ?1",
70        )?;
71        match stmt.query_row(params![key], |row| {
72            Ok(AgentWanKeypair {
73                public_key: row.get(0)?,
74                private_key: row.get(1)?,
75                key_version: row.get(2)?,
76            })
77        }) {
78            Ok(v) => Ok(Some(v)),
79            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
80            Err(e) => Err(e.into()),
81        }
82    }
83
84    /// Return this agent's WAN keypair, minting and persisting a fresh random
85    /// one on first use. Race-safe under concurrent first-use — same
86    /// `INSERT OR IGNORE` + re-read pattern as `agent_lan_key_ensure`: two
87    /// concurrent callers for the same never-before-seen agent_id agree on one
88    /// keypair instead of each minting and using a different one.
89    ///
90    /// Note there is deliberately no TTL/rotation branch here yet, unlike
91    /// `agent_jekt_key_ensure`. Rotation needs the continuity chain described
92    /// in the spec's §3.6 (a new key signed by its predecessor) to be
93    /// distinguishable from a registry rewrite; rotating before that exists
94    /// would trade one gap for a worse one.
95    pub fn agent_wan_key_ensure(&self, agent_id: &str) -> Result<AgentWanKeypair, StoreError> {
96        let key = agent_id.to_lowercase();
97        if let Some(existing) = self.agent_wan_key_load(&key)? {
98            return Ok(existing);
99        }
100        let conn = self.conn.lock().unwrap();
101        let seed = random_seed_bytes();
102        let (public_bytes, private_bytes) = agentmux_common::jekt_sign::generate_wan_keypair(seed);
103        let public_key = BASE64.encode(public_bytes);
104        let private_key = BASE64.encode(private_bytes);
105        conn.execute(
106            "INSERT OR IGNORE INTO db_agent_wan_keys (agent_id, public_key, private_key, key_version, created_at) \
107             VALUES (?1, ?2, ?3, 1, ?4)",
108            params![key, public_key, private_key, now_secs()],
109        )?;
110        // Re-read regardless of whether our insert won the race — single
111        // source of truth for "what keypair does this agent have," not an
112        // assumption that our own insert succeeded.
113        let mut stmt = conn.prepare(
114            "SELECT public_key, private_key, key_version FROM db_agent_wan_keys WHERE agent_id = ?1",
115        )?;
116        stmt.query_row(params![key], |row| {
117            Ok(AgentWanKeypair {
118                public_key: row.get(0)?,
119                private_key: row.get(1)?,
120                key_version: row.get(2)?,
121            })
122        })
123        .map_err(Into::into)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn object_store() -> Store {
132        Store::open(std::path::Path::new(":memory:")).unwrap()
133    }
134
135    #[test]
136    fn load_returns_none_when_unminted() {
137        let store = object_store();
138        assert!(store.agent_wan_key_load("agentx").unwrap().is_none());
139    }
140
141    #[test]
142    fn ensure_mints_a_valid_base64_keypair() {
143        let store = object_store();
144        let keypair = store.agent_wan_key_ensure("agentx").unwrap();
145        let pub_bytes = BASE64.decode(&keypair.public_key).unwrap();
146        let priv_bytes = BASE64.decode(&keypair.private_key).unwrap();
147        assert_eq!(pub_bytes.len(), 32);
148        assert_eq!(priv_bytes.len(), 32);
149        assert_ne!(keypair.public_key, keypair.private_key);
150        assert_eq!(keypair.key_version, 1);
151    }
152
153    #[test]
154    fn ensure_is_idempotent_same_agent_gets_same_keypair() {
155        let store = object_store();
156        let first = store.agent_wan_key_ensure("AgentX").unwrap();
157        let second = store.agent_wan_key_ensure("agentx").unwrap(); // case-insensitive
158        assert_eq!(first, second);
159    }
160
161    #[test]
162    fn different_agents_get_different_keypairs() {
163        let store = object_store();
164        let a = store.agent_wan_key_ensure("agentx").unwrap();
165        let b = store.agent_wan_key_ensure("agenty").unwrap();
166        assert_ne!(a.public_key, b.public_key);
167        assert_ne!(a.private_key, b.private_key);
168    }
169
170    #[test]
171    fn ensure_then_load_round_trips() {
172        let store = object_store();
173        let minted = store.agent_wan_key_ensure("agentx").unwrap();
174        let loaded = store.agent_wan_key_load("agentx").unwrap().unwrap();
175        assert_eq!(minted, loaded);
176    }
177
178    #[test]
179    fn a_minted_keypair_can_sign_and_verify_a_real_jekt() {
180        // End-to-end sanity through the actual jekt_sign functions, not just
181        // storage round-tripping — proves the stored encoding is exactly what
182        // generate_wan_keypair/sign_wan_jekt/verify_wan_jekt expect.
183        let store = object_store();
184        let keypair = store.agent_wan_key_ensure("agentx").unwrap();
185        let private = BASE64.decode(&keypair.private_key).unwrap();
186        let public = BASE64.decode(&keypair.public_key).unwrap();
187        let sig = agentmux_common::jekt_sign::sign_wan_jekt(
188            &private, "msg-1", "agentx", "narko", "stable", "agenty", 1_000, "hello",
189        )
190        .unwrap();
191        assert!(agentmux_common::jekt_sign::verify_wan_jekt(
192            &public, "msg-1", "agentx", "narko", "stable", "agenty", 1_000, "hello", &sig,
193        ));
194    }
195
196    #[test]
197    fn an_agents_wan_and_lan_keypairs_are_independent() {
198        // The tables must not share key material — see this module's doc
199        // comment for why their lifecycles differ. A shared keypair would also
200        // make the domain separation in jekt_sign the only thing standing
201        // between a LAN signature and a WAN trust claim.
202        let store = object_store();
203        let wan = store.agent_wan_key_ensure("agentx").unwrap();
204        let lan = store.agent_lan_key_ensure("agentx").unwrap();
205        assert_ne!(wan.public_key, lan.public_key);
206        assert_ne!(wan.private_key, lan.private_key);
207    }
208}