Skip to main content

agentmux_srv/backend/storage/
lan_peer_pubkey_pins.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Trust-on-first-use pin of a REMOTE agent_id's LAN public key.
5//! See db_lan_peer_pubkey_pins in migrations.rs (OBJECT_SCHEMA_VERSION v20)
6//! and docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2.
7//!
8//! Distinct from `agent_lan_keys.rs` (this instance's OWN agents' keypairs,
9//! private half included) — this table holds only public keys OBSERVED from
10//! LAN peers for agent_ids this instance does not itself host.
11//!
12//! Why this exists (reagentx P0 on the LAN signing PR): mDNS peer discovery
13//! is unauthenticated — any device on the LAN can advertise its own
14//! AgentMux instance and locally register an agent under an EXISTING
15//! agent's name (e.g. "korp"). Without pinning, `find_agent_lan_pubkey`
16//! trusts "whichever peer answers the discovery query first," which an
17//! attacker can win just as easily as the legitimate owner — their
18//! self-minted keypair would then verify successfully as "korp," defeating
19//! the entire point of per-agent signing. Pinning the FIRST key ever
20//! observed for an agent_id, and treating a later, DIFFERENT key as a
21//! mismatch rather than silently accepting it, is the standard SSH-host-key
22//! mitigation for exactly this "no PKI available" situation. It does not
23//! eliminate spoofing entirely — an attacker who wins the race on the very
24//! first-ever lookup for a given agent_id still gets pinned as
25//! authoritative — but it closes the "always fully spoofable, forever"
26//! case down to a narrow first-contact race, the same residual risk any
27//! TOFU scheme accepts.
28
29use rusqlite::params;
30
31use super::error::StoreError;
32use super::store::Store;
33
34use agentmux_common::time::now_secs;
35
36impl Store {
37    /// Look up the pinned public key for a remote agent_id, if one exists.
38    #[cfg(test)]
39    pub fn lan_peer_pubkey_pin_load(&self, agent_id: &str) -> Result<Option<String>, StoreError> {
40        let key = agent_id.to_lowercase();
41        let conn = self.conn.lock().unwrap();
42        let mut stmt = conn.prepare("SELECT public_key FROM db_lan_peer_pubkey_pins WHERE agent_id = ?1")?;
43        match stmt.query_row(params![key], |row| row.get(0)) {
44            Ok(v) => Ok(Some(v)),
45            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
46            Err(e) => Err(e.into()),
47        }
48    }
49
50    /// Get-or-pin: if a pin already exists for this agent_id, returns it
51    /// UNCHANGED regardless of what `observed_key_b64` says (the whole
52    /// point — a later, different key must never silently overwrite an
53    /// established pin). If no pin exists yet, pins `observed_key_b64` and
54    /// returns it. Callers compare the return value against
55    /// `observed_key_b64`: equal means "trusted, use it"; a mismatch means
56    /// "this claimed sender's key just changed unexpectedly" — a red flag,
57    /// not a value to fall back to. Race-safe under concurrent first-use
58    /// via the same `INSERT OR IGNORE` + re-read pattern
59    /// `agent_jekt_key_ensure`/`agent_lan_key_ensure` already establish.
60    pub fn lan_peer_pubkey_pin_get_or_set(
61        &self,
62        agent_id: &str,
63        observed_key_b64: &str,
64    ) -> Result<String, StoreError> {
65        let key = agent_id.to_lowercase();
66        let conn = self.conn.lock().unwrap();
67        conn.execute(
68            "INSERT OR IGNORE INTO db_lan_peer_pubkey_pins (agent_id, public_key, first_seen_at) \
69             VALUES (?1, ?2, ?3)",
70            params![key, observed_key_b64, now_secs()],
71        )?;
72        let mut stmt = conn.prepare("SELECT public_key FROM db_lan_peer_pubkey_pins WHERE agent_id = ?1")?;
73        stmt.query_row(params![key], |row| row.get(0)).map_err(Into::into)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn object_store() -> Store {
82        Store::open(std::path::Path::new(":memory:")).unwrap()
83    }
84
85    #[test]
86    fn load_returns_none_when_unpinned() {
87        let store = object_store();
88        assert!(store.lan_peer_pubkey_pin_load("korp").unwrap().is_none());
89    }
90
91    #[test]
92    fn first_observation_pins_the_key() {
93        let store = object_store();
94        let pinned = store.lan_peer_pubkey_pin_get_or_set("korp", "key-a").unwrap();
95        assert_eq!(pinned, "key-a");
96        assert_eq!(store.lan_peer_pubkey_pin_load("korp").unwrap().as_deref(), Some("key-a"));
97    }
98
99    #[test]
100    fn a_later_different_key_does_not_overwrite_the_pin() {
101        let store = object_store();
102        store.lan_peer_pubkey_pin_get_or_set("korp", "key-a").unwrap();
103        // A different peer (or an attacker) now claims a different key for
104        // the same agent_id — the ORIGINAL pin must win, not the newer claim.
105        let result = store.lan_peer_pubkey_pin_get_or_set("korp", "key-b-attacker").unwrap();
106        assert_eq!(
107            result, "key-a",
108            "the first-pinned key must be returned regardless of what a later observation claims"
109        );
110        assert_eq!(store.lan_peer_pubkey_pin_load("korp").unwrap().as_deref(), Some("key-a"));
111    }
112
113    #[test]
114    fn repeated_observation_of_the_same_key_is_idempotent() {
115        let store = object_store();
116        store.lan_peer_pubkey_pin_get_or_set("korp", "key-a").unwrap();
117        let result = store.lan_peer_pubkey_pin_get_or_set("korp", "key-a").unwrap();
118        assert_eq!(result, "key-a");
119    }
120
121    #[test]
122    fn different_agent_ids_pin_independently() {
123        let store = object_store();
124        store.lan_peer_pubkey_pin_get_or_set("korp", "key-a").unwrap();
125        store.lan_peer_pubkey_pin_get_or_set("loap", "key-b").unwrap();
126        assert_eq!(store.lan_peer_pubkey_pin_load("korp").unwrap().as_deref(), Some("key-a"));
127        assert_eq!(store.lan_peer_pubkey_pin_load("loap").unwrap().as_deref(), Some("key-b"));
128    }
129
130    #[test]
131    fn agent_id_lookup_is_case_insensitive() {
132        let store = object_store();
133        store.lan_peer_pubkey_pin_get_or_set("Korp", "key-a").unwrap();
134        assert_eq!(store.lan_peer_pubkey_pin_load("korp").unwrap().as_deref(), Some("key-a"));
135        let result = store.lan_peer_pubkey_pin_get_or_set("KORP", "key-b").unwrap();
136        assert_eq!(result, "key-a", "case-insensitive pin lookup must find the existing pin");
137    }
138}