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}