Skip to main content

agentmux_srv/backend/storage/
wan_identity.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The channel-wide WAN identity store, `<channel dir>/wan-identity/wan.db`
5//! (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.2, step D1).
6//!
7//! Everything WAN verification needs to survive an upgrade lives here rather
8//! than in `objects.db`, which is per *version* for installed builds
9//! (`channels/<ch>/versions/<v>/data/`): with the agent keys in `objects.db`,
10//! every upgrade re-minted every agent's WAN key. This file holds:
11//!  - the **instance keypair** — the install's long-lived identity. Its id
12//!    (`jekt_sign::wan_instance_id`) is what agents sign as their
13//!    `AGENTMUX_HOST_LABEL`, and its key certifies each agent key;
14//!  - the **agent WAN keys**, imported once from the version's
15//!    `db_agent_wan_keys` (or minted fresh) and kept here from then on;
16//!  - (D1b/D2) published fingerprints, the peer cache, replay and
17//!    known-instance tables.
18//!
19//! **Concurrency.** Two versions of one channel may run at once
20//! (`data_paths.rs`), so this file is shared between processes: WAL, a 5 s
21//! busy timeout, and every mint is `INSERT OR IGNORE` then re-read, so two
22//! processes minting at once converge on one row.
23//!
24//! **Schema is additive only.** `wan_meta.schema_version` records the newest
25//! schema any binary has written; an older binary opening a newer file uses
26//! explicit column lists and ignores what it doesn't know. A binary that
27//! cannot open the file gets no store at all — WAN signing and verification
28//! are then off for that process (every message `None`), and nothing is ever
29//! minted into a fallback location.
30//!
31//! **Lock order.** The spawn path reads an agent's old `objects.db` key
32//! *before* calling in here, and the agent-delete purge calls in here while
33//! holding the `objects.db` lock. So the order is always `objects.db` →
34//! `wan.db`; nothing here ever takes the `objects.db` lock.
35
36use std::path::{Path, PathBuf};
37use std::sync::Mutex;
38use std::time::Duration;
39
40use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
41use rusqlite::{params, Connection, OptionalExtension};
42
43use super::agent_lan_keys::random_seed_bytes;
44use super::error::StoreError;
45
46/// Bumped only by additive changes (new tables, new nullable columns).
47/// v2 (D2): the receiver tables — peer-record cache, known instances, and
48/// seen signatures.
49/// v3: `wan_agent_key_publications` — publication recorded per directory.
50const SCHEMA_VERSION: i64 = 3;
51
52const SCHEMA: &str = "
53    CREATE TABLE IF NOT EXISTS wan_meta (
54        key   TEXT PRIMARY KEY,
55        value INTEGER NOT NULL
56    );
57    -- One row, ever. `singleton` pins it so a concurrent first mint
58    -- collides instead of creating a second instance.
59    CREATE TABLE IF NOT EXISTS wan_instance (
60        singleton   INTEGER PRIMARY KEY CHECK (singleton = 1),
61        public_key  TEXT    NOT NULL,
62        private_key TEXT    NOT NULL,
63        instance_id TEXT    NOT NULL,
64        host_hint   TEXT    NOT NULL,
65        created_at  INTEGER NOT NULL
66    );
67    CREATE TABLE IF NOT EXISTS wan_agent_keys (
68        agent_id     TEXT    PRIMARY KEY,
69        public_key   TEXT    NOT NULL,
70        private_key  TEXT    NOT NULL,
71        created_at   INTEGER NOT NULL,
72        -- 1 when the key was carried over from objects.db's
73        -- db_agent_wan_keys rather than minted here.
74        imported     INTEGER NOT NULL DEFAULT 0,
75        -- D1b: the fingerprint of the certificate confirmed published to the
76        -- cloud directory, and when. Superseded by wan_agent_key_publications
77        -- (v3), which records WHICH directory; no longer read or written here.
78        published_fp TEXT,
79        published_at INTEGER
80    );
81    -- v3: which key fingerprint each directory is confirmed to hold. A
82    -- directory is a relay plus the account it was published under
83    -- (`wan_publish::directory_id`). A sign-in to another relay or account
84    -- simply has no row yet, so the key is pending there — no reset needed,
85    -- whichever process or channel of this file did the publishing.
86    CREATE TABLE IF NOT EXISTS wan_agent_key_publications (
87        agent_id     TEXT    NOT NULL,
88        directory    TEXT    NOT NULL,
89        key_fp       TEXT    NOT NULL,
90        published_at INTEGER NOT NULL,
91        PRIMARY KEY (agent_id, directory)
92    );
93    -- v2 (D2, receiver side) --
94    -- Directory records fetched for verification. Self-certifying and
95    -- content-addressed by the key fingerprint, so a cached record never
96    -- expires; re-checked against its chain on every use.
97    CREATE TABLE IF NOT EXISTS wan_peer_records (
98        instance_id TEXT    NOT NULL,
99        agent_id    TEXT    NOT NULL,
100        channel     TEXT    NOT NULL,
101        key_fp      TEXT    NOT NULL,
102        record      TEXT    NOT NULL,
103        fetched_at  INTEGER NOT NULL,
104        PRIMARY KEY (instance_id, agent_id, channel, key_fp)
105    );
106    -- Every verified instance, recorded on first sight (§2.6). approved_at is
107    -- written only by the host-gated approval window, which ships disabled;
108    -- revoked_at is sticky.
109    CREATE TABLE IF NOT EXISTS wan_known_instances (
110        instance_id           TEXT    PRIMARY KEY,
111        host_hint             TEXT    NOT NULL,
112        first_seen_at         INTEGER NOT NULL,
113        approved_at           INTEGER,
114        revoked_at            INTEGER,
115        revocation_checked_at INTEGER
116    );
117    -- (instance, agent, msgid) already delivered (§2.5). Written after
118    -- successful local delivery; pruned once the message could no longer
119    -- pass freshness.
120    CREATE TABLE IF NOT EXISTS wan_seen_sigs (
121        instance_id TEXT    NOT NULL,
122        agent_id    TEXT    NOT NULL,
123        msg_id      TEXT    NOT NULL,
124        expires_at  INTEGER NOT NULL,
125        PRIMARY KEY (instance_id, agent_id, msg_id)
126    );
127";
128
129/// This install's instance identity. The private key never leaves this
130/// process except as a certificate signature.
131#[derive(Clone, PartialEq, Eq)]
132pub struct WanInstance {
133    pub instance_id: String,
134    pub public_key: [u8; 32],
135    pub private_key: [u8; 32],
136    /// The hostname when the instance was minted, sanitised to
137    /// `[a-z0-9.-]{1,48}`. Never re-read from the live hostname: renaming
138    /// the machine must not change what peers see.
139    pub host_hint: String,
140    pub created_at: i64,
141}
142
143impl std::fmt::Debug for WanInstance {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("WanInstance")
146            .field("instance_id", &self.instance_id)
147            .field("host_hint", &self.host_hint)
148            .field("created_at", &self.created_at)
149            .finish_non_exhaustive()
150    }
151}
152
153/// An agent's WAN keypair, both halves standard base64 — the same encoding
154/// `agent_wan_keys::AgentWanKeypair` uses, so an import is a straight copy
155/// and `AGENTMUX_WAN_KEY` is unchanged for the MCP.
156#[derive(Clone, PartialEq, Eq)]
157pub struct WanAgentKey {
158    pub public_key: String,
159    pub private_key: String,
160    pub imported: bool,
161    /// When this store first held the key. Doubles as the certificate's
162    /// `issued_at`, so re-certifying a key produces the identical record and
163    /// a re-publish is a no-op at the directory.
164    pub created_at: i64,
165}
166
167impl std::fmt::Debug for WanAgentKey {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("WanAgentKey")
170            .field("public_key", &self.public_key)
171            .field("imported", &self.imported)
172            .field("created_at", &self.created_at)
173            .finish_non_exhaustive()
174    }
175}
176
177impl WanAgentKey {
178    /// The raw public key, if the stored base64 is well-formed.
179    pub fn public_key_bytes(&self) -> Option<[u8; 32]> {
180        decode_32(&self.public_key)
181    }
182
183}
184
185/// What this install knows about another instance (§2.6).
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct KnownInstance {
188    pub approved_at: Option<i64>,
189    pub revoked_at: Option<i64>,
190    pub revocation_checked_at: Option<i64>,
191}
192
193pub struct WanIdentityStore {
194    conn: Mutex<Connection>,
195}
196
197static GLOBAL: std::sync::OnceLock<std::sync::Arc<WanIdentityStore>> = std::sync::OnceLock::new();
198
199/// Register the process's `wan.db` for code that has no channel object store
200/// in hand — the cloud subscriber runs against `id_store`. Set once, at
201/// bootstrap, alongside `Store::set_wan_identity`.
202pub fn install_global(store: std::sync::Arc<WanIdentityStore>) {
203    let _ = GLOBAL.set(store);
204}
205
206pub fn global() -> Option<std::sync::Arc<WanIdentityStore>> {
207    GLOBAL.get().cloned()
208}
209
210/// `<channel dir>/wan-identity/wan.db`, from the launcher-provided paths.
211/// `None` outside a launcher-started srv (tests, odd envs): WAN identity is
212/// then off, never redirected somewhere else.
213pub fn resolve_wan_identity_path() -> Option<PathBuf> {
214    agentmux_common::DataPaths::from_env().map(|paths| paths.wan_identity_dir().join("wan.db"))
215}
216
217/// A hostname as a host hint: lowercased, anything outside `[a-z0-9.-]`
218/// replaced by `-`, cut to 48 chars; `unknown` if nothing is left. The
219/// receiver validates the same alphabet (`jekt_sign::is_valid_wan_host_hint`)
220/// and renders anything else as `?`.
221pub fn sanitize_host_hint(hostname: &str) -> String {
222    let hint: String = hostname
223        .trim()
224        .to_lowercase()
225        .chars()
226        .map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-' { c } else { '-' })
227        .take(48)
228        .collect();
229    if hint.is_empty() {
230        "unknown".to_string()
231    } else {
232        hint
233    }
234}
235
236fn decode_32(b64: &str) -> Option<[u8; 32]> {
237    BASE64.decode(b64).ok()?.try_into().ok()
238}
239
240const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
241
242fn is_busy(e: &rusqlite::Error) -> bool {
243    matches!(
244        e,
245        rusqlite::Error::SqliteFailure(f, _)
246            if matches!(f.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)
247    )
248}
249
250/// Run `op` until it stops failing with SQLITE_BUSY, for at most
251/// [`BUSY_TIMEOUT`]. Any other error, or BUSY past the deadline, is returned.
252fn retry_while_busy<T>(mut op: impl FnMut() -> Result<T, rusqlite::Error>) -> Result<T, rusqlite::Error> {
253    let deadline = std::time::Instant::now() + BUSY_TIMEOUT;
254    let mut backoff = Duration::from_millis(5);
255    loop {
256        match op() {
257            Err(e) if is_busy(&e) && std::time::Instant::now() < deadline => {
258                std::thread::sleep(backoff);
259                backoff = (backoff * 2).min(Duration::from_millis(100));
260            }
261            result => return result,
262        }
263    }
264}
265
266#[cfg(unix)]
267fn restrict_permissions(dir: &Path, file: &Path) {
268    use std::os::unix::fs::PermissionsExt;
269    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
270    let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
271}
272
273// Windows: the file inherits the per-user ACL of the profile directory it
274// lives under; there is no mode bit to set.
275#[cfg(not(unix))]
276fn restrict_permissions(_dir: &Path, _file: &Path) {}
277
278impl WanIdentityStore {
279    /// Open (creating if needed) the store at `path`. Errors if the file
280    /// can't be opened or its schema can't be ensured; the caller then runs
281    /// without WAN identity.
282    pub fn open(path: &Path) -> Result<Self, StoreError> {
283        if let Some(dir) = path.parent() {
284            std::fs::create_dir_all(dir).map_err(|e| StoreError::Other(format!("wan-identity dir: {e}")))?;
285        }
286        let conn = Connection::open(path)?;
287        conn.busy_timeout(BUSY_TIMEOUT)?;
288        // Several processes opening a brand-new file at once (two versions of
289        // a channel starting together) can get SQLITE_BUSY straight back from
290        // the journal-mode switch or the first schema write: SQLite skips the
291        // busy handler where waiting could deadlock. Measured — 8 concurrent
292        // opens failed this way within a few runs. So the one-time setup is
293        // retried as a whole, within the same bound as the busy timeout.
294        retry_while_busy(|| {
295            conn.pragma_update(None, "journal_mode", "WAL")?;
296            // IMMEDIATE so two processes creating the schema at once
297            // serialise on the write lock instead of one failing mid-way.
298            conn.execute_batch(&format!("BEGIN IMMEDIATE; {SCHEMA} COMMIT;")).inspect_err(|_| {
299                let _ = conn.execute_batch("ROLLBACK;");
300            })?;
301            conn.execute(
302                "INSERT INTO wan_meta (key, value) VALUES ('schema_version', ?1) \
303                 ON CONFLICT(key) DO UPDATE SET value = MAX(value, excluded.value)",
304                params![SCHEMA_VERSION],
305            )?;
306            Ok(())
307        })?;
308        if let Some(dir) = path.parent() {
309            restrict_permissions(dir, path);
310        }
311        Ok(Self { conn: Mutex::new(conn) })
312    }
313
314    /// The newest schema version any binary has written to this file.
315    #[cfg(test)]
316    pub fn schema_version(&self) -> Result<i64, StoreError> {
317        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
318        Ok(conn.query_row("SELECT value FROM wan_meta WHERE key = 'schema_version'", [], |r| r.get(0))?)
319    }
320
321    /// This install's instance, minted on first call. `hostname` is read
322    /// only at mint time and becomes the permanent host hint.
323    pub fn instance_ensure(&self, hostname: &str) -> Result<WanInstance, StoreError> {
324        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
325        if let Some(existing) = Self::instance_load(&conn)? {
326            return Ok(existing);
327        }
328        let (public_key, private_key) = agentmux_common::jekt_sign::generate_wan_keypair(random_seed_bytes());
329        conn.execute(
330            "INSERT OR IGNORE INTO wan_instance \
331             (singleton, public_key, private_key, instance_id, host_hint, created_at) \
332             VALUES (1, ?1, ?2, ?3, ?4, ?5)",
333            params![
334                BASE64.encode(public_key),
335                BASE64.encode(private_key),
336                agentmux_common::jekt_sign::wan_instance_id(&public_key),
337                sanitize_host_hint(hostname),
338                agentmux_common::time::now_secs(),
339            ],
340        )?;
341        // Re-read whether or not this insert won: another process may have
342        // minted first, and its instance is the one.
343        Self::instance_load(&conn)?.ok_or_else(|| StoreError::Other("wan_instance row missing after mint".into()))
344    }
345
346    fn instance_load(conn: &Connection) -> Result<Option<WanInstance>, StoreError> {
347        let row = conn
348            .query_row(
349                "SELECT public_key, private_key, instance_id, host_hint, created_at FROM wan_instance WHERE singleton = 1",
350                [],
351                |r| {
352                    Ok((
353                        r.get::<_, String>(0)?,
354                        r.get::<_, String>(1)?,
355                        r.get::<_, String>(2)?,
356                        r.get::<_, String>(3)?,
357                        r.get::<_, i64>(4)?,
358                    ))
359                },
360            )
361            .optional()?;
362        let Some((public_b64, private_b64, instance_id, host_hint, created_at)) = row else {
363            return Ok(None);
364        };
365        let (Some(public_key), Some(private_key)) = (decode_32(&public_b64), decode_32(&private_b64)) else {
366            return Err(StoreError::Other("wan_instance holds a malformed key".into()));
367        };
368        // The id is derived, but stored for readability; a row whose id no
369        // longer matches its key is corrupt, not an instance.
370        if agentmux_common::jekt_sign::wan_instance_id(&public_key) != instance_id {
371            return Err(StoreError::Other("wan_instance id does not match its key".into()));
372        }
373        Ok(Some(WanInstance { instance_id, public_key, private_key, host_hint, created_at }))
374    }
375
376    /// An agent's WAN key, if this store holds one.
377    pub fn agent_key_load(&self, agent_id: &str) -> Result<Option<WanAgentKey>, StoreError> {
378        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
379        Self::agent_key_load_locked(&conn, &agent_id.to_lowercase())
380    }
381
382    const AGENT_KEY_COLUMNS: &'static str = "public_key, private_key, imported, created_at";
383
384    fn agent_key_from_row(r: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<WanAgentKey> {
385        Ok(WanAgentKey {
386            public_key: r.get(offset)?,
387            private_key: r.get(offset + 1)?,
388            imported: r.get::<_, i64>(offset + 2)? != 0,
389            created_at: r.get(offset + 3)?,
390        })
391    }
392
393    fn agent_key_load_locked(conn: &Connection, key: &str) -> Result<Option<WanAgentKey>, StoreError> {
394        Ok(conn
395            .query_row(
396                &format!("SELECT {} FROM wan_agent_keys WHERE agent_id = ?1", Self::AGENT_KEY_COLUMNS),
397                params![key],
398                |r| Self::agent_key_from_row(r, 0),
399            )
400            .optional()?)
401    }
402
403    /// Every agent key `directory` is not confirmed to hold, by agent id —
404    /// what the publisher (`muxbus/wan_publish.rs`) works through.
405    pub fn agent_keys_unpublished(&self, directory: &str) -> Result<Vec<(String, WanAgentKey)>, StoreError> {
406        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
407        let mut stmt = conn.prepare(&format!(
408            "SELECT k.agent_id, {}, p.key_fp FROM wan_agent_keys k \
409             LEFT JOIN wan_agent_key_publications p ON p.agent_id = k.agent_id AND p.directory = ?1 \
410             ORDER BY k.agent_id",
411            Self::AGENT_KEY_COLUMNS.split(", ").map(|c| format!("k.{c}")).collect::<Vec<_>>().join(", ")
412        ))?;
413        let rows = stmt.query_map(params![directory], |r| {
414            Ok((r.get::<_, String>(0)?, Self::agent_key_from_row(r, 1)?, r.get::<_, Option<String>>(5)?))
415        })?;
416        let mut out = Vec::new();
417        for row in rows {
418            let (agent_id, key, published_fp) = row?;
419            if !Self::fp_matches(&key, published_fp.as_deref()) {
420                out.push((agent_id, key));
421            }
422        }
423        Ok(out)
424    }
425
426    /// Whether `directory` is confirmed to hold `key`, `agent_id`'s current
427    /// key — the relay carry gate's condition 4 (`relay::wan_carry_gate`).
428    pub fn agent_key_is_published_to(&self, agent_id: &str, key: &WanAgentKey, directory: &str) -> Result<bool, StoreError> {
429        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
430        let fp: Option<String> = conn
431            .query_row(
432                "SELECT key_fp FROM wan_agent_key_publications WHERE agent_id = ?1 AND directory = ?2",
433                params![agent_id.to_lowercase(), directory],
434                |r| r.get(0),
435            )
436            .optional()?;
437        Ok(Self::fp_matches(key, fp.as_deref()))
438    }
439
440    fn fp_matches(key: &WanAgentKey, published_fp: Option<&str>) -> bool {
441        match (key.public_key_bytes(), published_fp) {
442            (Some(public), Some(fp)) => agentmux_common::jekt_sign::wan_key_fingerprint(&public) == fp,
443            _ => false,
444        }
445    }
446
447    /// Record that `directory` accepted `public_key`'s certificate. Only
448    /// marks if the row still holds that key — an agent deleted and
449    /// recreated while the PUT was in flight must not have its new key marked
450    /// published on the strength of the old one's. `directory` must be the
451    /// one the PUT went to, so a PUT that returns after a sign-in to another
452    /// account marks the old directory, not the new one.
453    pub fn agent_key_mark_published(&self, agent_id: &str, public_key: &str, directory: &str) -> Result<bool, StoreError> {
454        let Some(public) = decode_32(public_key) else { return Ok(false) };
455        let fp = agentmux_common::jekt_sign::wan_key_fingerprint(&public);
456        let agent_id = agent_id.to_lowercase();
457        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
458        let updated = conn.execute(
459            "INSERT INTO wan_agent_key_publications (agent_id, directory, key_fp, published_at) \
460             SELECT ?1, ?2, ?3, ?4 WHERE EXISTS \
461               (SELECT 1 FROM wan_agent_keys WHERE agent_id = ?1 AND public_key = ?5) \
462             ON CONFLICT (agent_id, directory) DO UPDATE SET key_fp = excluded.key_fp, published_at = excluded.published_at",
463            params![agent_id, directory, fp, agentmux_common::time::now_secs(), public_key],
464        )?;
465        Ok(updated > 0)
466    }
467
468    /// An agent's WAN key, created on first call: `import` (the agent's key
469    /// from this version's `db_agent_wan_keys`, read by the caller) is
470    /// carried over if given, otherwise a fresh key is minted. Only the first
471    /// call for an agent decides — after that, `import` is ignored, so an
472    /// agent keeps one key across upgrades. Case-insensitive, like every key
473    /// table.
474    pub fn agent_key_ensure(&self, agent_id: &str, import: Option<(&str, &str)>) -> Result<WanAgentKey, StoreError> {
475        let key = agent_id.to_lowercase();
476        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
477        if let Some(existing) = Self::agent_key_load_locked(&conn, &key)? {
478            return Ok(existing);
479        }
480        // An import must be a real keypair; anything else is minted over.
481        let valid_import = import.filter(|(public_b64, private_b64)| {
482            match (decode_32(public_b64), decode_32(private_b64)) {
483                (Some(public), Some(private)) => agentmux_common::jekt_sign::generate_wan_keypair(private).0 == public,
484                _ => false,
485            }
486        });
487        let (public_key, private_key, imported) = match valid_import {
488            Some((public_b64, private_b64)) => (public_b64.to_string(), private_b64.to_string(), true),
489            None => {
490                let (public, private) = agentmux_common::jekt_sign::generate_wan_keypair(random_seed_bytes());
491                (BASE64.encode(public), BASE64.encode(private), false)
492            }
493        };
494        conn.execute(
495            "INSERT OR IGNORE INTO wan_agent_keys (agent_id, public_key, private_key, created_at, imported) \
496             VALUES (?1, ?2, ?3, ?4, ?5)",
497            params![key, public_key, private_key, agentmux_common::time::now_secs(), imported as i64],
498        )?;
499        Self::agent_key_load_locked(&conn, &key)?
500            .ok_or_else(|| StoreError::Other("wan_agent_keys row missing after mint".into()))
501    }
502
503    // ── Receiver side (D2, §2.3/§2.5/§2.6) ──
504
505    /// A cached directory record for exactly this (instance, agent, channel,
506    /// fingerprint). The caller re-checks its chain; a row that no longer
507    /// parses is treated as absent.
508    pub fn peer_record_get(
509        &self,
510        instance_id: &str,
511        agent_id: &str,
512        channel: &str,
513        key_fp: &str,
514    ) -> Result<Option<agentmux_common::jekt_sign::WanKeyRecord>, StoreError> {
515        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
516        let json: Option<String> = conn
517            .query_row(
518                "SELECT record FROM wan_peer_records \
519                 WHERE instance_id = ?1 AND agent_id = ?2 AND channel = ?3 AND key_fp = ?4",
520                params![instance_id.to_lowercase(), agent_id.to_lowercase(), channel, key_fp],
521                |r| r.get(0),
522            )
523            .optional()?;
524        Ok(json.and_then(|j| serde_json::from_str(&j).ok()))
525    }
526
527    /// Cache a record whose chain the caller has checked.
528    pub fn peer_record_put(&self, record: &agentmux_common::jekt_sign::WanKeyRecord) -> Result<(), StoreError> {
529        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
530        conn.execute(
531            "INSERT OR REPLACE INTO wan_peer_records \
532             (instance_id, agent_id, channel, key_fp, record, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
533            params![
534                record.instance_id,
535                record.agent_id,
536                record.channel,
537                record.key_fp,
538                serde_json::to_string(record)?,
539                agentmux_common::time::now_secs(),
540            ],
541        )?;
542        Ok(())
543    }
544
545    /// Forget every cached peer record — on logout or account switch (§2.3).
546    pub fn peer_cache_clear(&self) -> Result<(), StoreError> {
547        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
548        conn.execute("DELETE FROM wan_peer_records", [])?;
549        Ok(())
550    }
551
552    /// Record `instance_id` as seen (first sight inserts it) and return what
553    /// is known about it.
554    pub fn known_instance_observe(&self, instance_id: &str, host_hint: &str, now: i64) -> Result<KnownInstance, StoreError> {
555        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
556        conn.execute(
557            "INSERT OR IGNORE INTO wan_known_instances (instance_id, host_hint, first_seen_at) VALUES (?1, ?2, ?3)",
558            params![instance_id, host_hint, now],
559        )?;
560        Ok(conn.query_row(
561            "SELECT approved_at, revoked_at, revocation_checked_at FROM wan_known_instances WHERE instance_id = ?1",
562            params![instance_id],
563            |r| {
564                Ok(KnownInstance {
565                    approved_at: r.get(0)?,
566                    revoked_at: r.get(1)?,
567                    revocation_checked_at: r.get(2)?,
568                })
569            },
570        )?)
571    }
572
573    /// Record a revocation-status check. `revoked` is sticky: a later check
574    /// that says "not revoked" never clears it.
575    pub fn known_instance_record_revocation_check(
576        &self,
577        instance_id: &str,
578        revoked: bool,
579        now: i64,
580    ) -> Result<(), StoreError> {
581        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
582        conn.execute(
583            "UPDATE wan_known_instances SET revocation_checked_at = ?2, \
584             revoked_at = CASE WHEN ?3 THEN COALESCE(revoked_at, ?2) ELSE revoked_at END \
585             WHERE instance_id = ?1",
586            params![instance_id, now, revoked],
587        )?;
588        Ok(())
589    }
590
591    /// Whether this (instance, agent, msgid) was already delivered.
592    pub fn seen_sig_contains(&self, instance_id: &str, agent_id: &str, msg_id: &str) -> Result<bool, StoreError> {
593        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
594        Ok(conn
595            .query_row(
596                "SELECT 1 FROM wan_seen_sigs WHERE instance_id = ?1 AND agent_id = ?2 AND msg_id = ?3",
597                params![instance_id.to_lowercase(), agent_id.to_lowercase(), msg_id],
598                |_| Ok(()),
599            )
600            .optional()?
601            .is_some())
602    }
603
604    /// Record a delivered signature until `expires_at`, and prune the rows
605    /// that could no longer pass freshness at `now` — the same clock the
606    /// verifier's freshness check used, so a row is never pruned while its
607    /// message could still verify.
608    pub fn seen_sig_record(
609        &self,
610        instance_id: &str,
611        agent_id: &str,
612        msg_id: &str,
613        expires_at: i64,
614        now: i64,
615    ) -> Result<(), StoreError> {
616        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
617        conn.execute(
618            "INSERT OR IGNORE INTO wan_seen_sigs (instance_id, agent_id, msg_id, expires_at) VALUES (?1, ?2, ?3, ?4)",
619            params![instance_id.to_lowercase(), agent_id.to_lowercase(), msg_id, expires_at],
620        )?;
621        conn.execute("DELETE FROM wan_seen_sigs WHERE expires_at < ?1", params![now])?;
622        Ok(())
623    }
624
625    /// Delete the keys filed under each name (folded as the key tables fold).
626    /// The agent-delete purge (`storage/agents/name_keys.rs`) passes exactly the names
627    /// it decided no other agent signs under, so a later agent that takes a
628    /// name never inherits the dead agent's key. Returns rows removed.
629    pub fn agent_keys_delete(&self, names: &[String]) -> Result<usize, StoreError> {
630        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
631        let mut removed = 0;
632        for name in names {
633            removed += conn.execute("DELETE FROM wan_agent_keys WHERE agent_id = ?1", params![name.to_lowercase()])?;
634        }
635        Ok(removed)
636    }
637}
638
639/// Test helper: attach a fresh `wan.db` in a temp dir to `store`, as
640/// bootstrap does for the runtime object store. Keep the returned dir alive
641/// for the test's duration.
642#[cfg(test)]
643pub fn attach_temp_wan_identity(store: &super::store::Store) -> (tempfile::TempDir, std::sync::Arc<WanIdentityStore>) {
644    let dir = tempfile::tempdir().unwrap();
645    let wan = std::sync::Arc::new(WanIdentityStore::open(&dir.path().join("wan.db")).unwrap());
646    store.set_wan_identity(wan.clone());
647    (dir, wan)
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use std::sync::Arc;
654
655    fn temp_store() -> (tempfile::TempDir, WanIdentityStore) {
656        let dir = tempfile::tempdir().unwrap();
657        let store = WanIdentityStore::open(&dir.path().join("wan-identity").join("wan.db")).unwrap();
658        (dir, store)
659    }
660
661    #[test]
662    fn an_instance_is_minted_once_and_its_id_is_its_key_hash() {
663        let (_dir, store) = temp_store();
664        let first = store.instance_ensure("narko").unwrap();
665        assert_eq!(first.instance_id, agentmux_common::jekt_sign::wan_instance_id(&first.public_key));
666        assert_eq!(first.instance_id.len(), agentmux_common::jekt_sign::WAN_INSTANCE_ID_LEN);
667        assert_eq!(
668            agentmux_common::jekt_sign::generate_wan_keypair(first.private_key).0,
669            first.public_key,
670            "the stored private key must be the public key's seed"
671        );
672        assert_eq!(store.instance_ensure("narko").unwrap(), first);
673    }
674
675    #[test]
676    fn the_instance_survives_reopening_as_after_an_upgrade() {
677        // An upgrade starts a new objects.db but opens this same file.
678        let dir = tempfile::tempdir().unwrap();
679        let path = dir.path().join("wan.db");
680        let before = WanIdentityStore::open(&path).unwrap().instance_ensure("narko").unwrap();
681        let after = WanIdentityStore::open(&path).unwrap().instance_ensure("narko").unwrap();
682        assert_eq!(before, after);
683    }
684
685    #[test]
686    fn the_host_hint_is_fixed_at_mint_and_does_not_follow_a_rename() {
687        let (_dir, store) = temp_store();
688        let minted = store.instance_ensure("Narko").unwrap();
689        assert_eq!(minted.host_hint, "narko");
690        assert_eq!(store.instance_ensure("renamed-box").unwrap().host_hint, "narko");
691    }
692
693    #[test]
694    fn host_hints_are_sanitised_to_what_receivers_accept() {
695        assert_eq!(sanitize_host_hint("Narko"), "narko");
696        assert_eq!(sanitize_host_hint("DESKTOP_AB12 (2)"), "desktop-ab12--2-");
697        assert_eq!(sanitize_host_hint(""), "unknown");
698        assert_eq!(sanitize_host_hint(&"x".repeat(60)).len(), 48);
699        for host in ["Narko", "DESKTOP_AB12 (2)", "", "münchen", &"x".repeat(60)] {
700            assert!(agentmux_common::jekt_sign::is_valid_wan_host_hint(&sanitize_host_hint(host)), "{host:?}");
701        }
702    }
703
704    #[test]
705    fn two_processes_minting_at_once_converge_on_one_instance() {
706        let dir = tempfile::tempdir().unwrap();
707        let path = Arc::new(dir.path().join("wan.db"));
708        // Separate connections, as two srv processes would have.
709        let handles: Vec<_> = (0..8)
710            .map(|i| {
711                let path = path.clone();
712                std::thread::spawn(move || {
713                    WanIdentityStore::open(&path).unwrap().instance_ensure(&format!("host{i}")).unwrap().instance_id
714                })
715            })
716            .collect();
717        let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
718        assert!(ids.windows(2).all(|w| w[0] == w[1]), "instances diverged: {ids:?}");
719    }
720
721    #[test]
722    fn two_processes_minting_one_agent_at_once_converge_on_one_key() {
723        let dir = tempfile::tempdir().unwrap();
724        let path = Arc::new(dir.path().join("wan.db"));
725        let handles: Vec<_> = (0..8)
726            .map(|_| {
727                let path = path.clone();
728                std::thread::spawn(move || {
729                    WanIdentityStore::open(&path).unwrap().agent_key_ensure("camper", None).unwrap().public_key
730                })
731            })
732            .collect();
733        let keys: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
734        assert!(keys.windows(2).all(|w| w[0] == w[1]), "agent keys diverged");
735    }
736
737    #[test]
738    fn an_older_binary_opens_a_newer_file() {
739        let dir = tempfile::tempdir().unwrap();
740        let path = dir.path().join("wan.db");
741        {
742            // A future schema: a newer version stamp, an extra table and an
743            // extra column on an existing one.
744            let conn = Connection::open(&path).unwrap();
745            conn.execute_batch(SCHEMA).unwrap();
746            conn.execute_batch(
747                "INSERT INTO wan_meta (key, value) VALUES ('schema_version', 7);
748                 CREATE TABLE wan_future (x INTEGER);
749                 ALTER TABLE wan_agent_keys ADD COLUMN future_col TEXT;",
750            )
751            .unwrap();
752        }
753        let store = WanIdentityStore::open(&path).unwrap();
754        assert_eq!(store.schema_version().unwrap(), 7, "an older binary must not lower the stamp");
755        store.instance_ensure("narko").unwrap();
756        store.agent_key_ensure("camper", None).unwrap();
757    }
758
759    #[test]
760    fn a_store_that_cannot_open_is_an_error_not_a_fallback() {
761        let dir = tempfile::tempdir().unwrap();
762        // The db path is a directory: SQLite can't open it.
763        let path = dir.path().join("wan.db");
764        std::fs::create_dir_all(&path).unwrap();
765        assert!(WanIdentityStore::open(&path).is_err());
766        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "nothing minted elsewhere");
767    }
768
769    #[test]
770    fn an_agent_key_is_imported_once_then_kept() {
771        let (_dir, store) = temp_store();
772        let (public, private) = agentmux_common::jekt_sign::generate_wan_keypair([7; 32]);
773        let (public_b64, private_b64) = (BASE64.encode(public), BASE64.encode(private));
774        let imported = store.agent_key_ensure("Camper", Some((&public_b64, &private_b64))).unwrap();
775        assert!(imported.imported);
776        assert_eq!(imported.public_key, public_b64);
777
778        // A later import (another version's objects.db) doesn't replace it.
779        let (other_public, other_private) = agentmux_common::jekt_sign::generate_wan_keypair([8; 32]);
780        let again = store
781            .agent_key_ensure("camper", Some((&BASE64.encode(other_public), &BASE64.encode(other_private))))
782            .unwrap();
783        assert_eq!(again, imported);
784    }
785
786    #[test]
787    fn a_malformed_or_mismatched_import_is_minted_over() {
788        let (_dir, store) = temp_store();
789        let (public, _) = agentmux_common::jekt_sign::generate_wan_keypair([7; 32]);
790        let (_, other_private) = agentmux_common::jekt_sign::generate_wan_keypair([8; 32]);
791        let key = store
792            .agent_key_ensure("camper", Some((&BASE64.encode(public), &BASE64.encode(other_private))))
793            .unwrap();
794        assert!(!key.imported);
795        assert_ne!(key.public_key, BASE64.encode(public));
796        let key2 = store.agent_key_ensure("lark", Some(("not base64", "x"))).unwrap();
797        assert!(!key2.imported);
798    }
799
800    #[test]
801    fn deleting_an_agents_keys_lets_a_new_agent_of_that_name_get_a_new_key() {
802        let (_dir, store) = temp_store();
803        let old = store.agent_key_ensure("camper", None).unwrap();
804        store.agent_key_ensure("lark", None).unwrap();
805        assert_eq!(store.agent_keys_delete(&["Camper".to_string(), "nobody".to_string()]).unwrap(), 1);
806        assert!(store.agent_key_load("camper").unwrap().is_none());
807        assert!(store.agent_key_load("lark").unwrap().is_some(), "other agents are untouched");
808        assert_ne!(store.agent_key_ensure("camper", None).unwrap().public_key, old.public_key);
809    }
810
811    #[test]
812    fn a_key_published_to_one_directory_is_pending_in_another() {
813        // A sign-in to another relay or account lands on a directory that has
814        // never seen these keys. Marks for the old one must not count there,
815        // or the publisher idles and every jekt from here arrives unverified.
816        let (_dir, store) = temp_store();
817        let camper = store.agent_key_ensure("camper", None).unwrap();
818        let (old, new) = ("https://relay#old-account", "https://relay#new-account");
819        assert!(store.agent_key_mark_published("camper", &camper.public_key, old).unwrap());
820
821        assert!(store.agent_keys_unpublished(old).unwrap().is_empty());
822        assert!(store.agent_key_is_published_to("camper", &camper, old).unwrap());
823        let pending: Vec<String> = store.agent_keys_unpublished(new).unwrap().into_iter().map(|(id, _)| id).collect();
824        assert_eq!(pending, vec!["camper".to_string()]);
825        assert!(!store.agent_key_is_published_to("camper", &camper, new).unwrap());
826
827        assert!(store.agent_key_mark_published("camper", &camper.public_key, new).unwrap());
828        assert!(store.agent_keys_unpublished(new).unwrap().is_empty());
829        assert!(store.agent_key_is_published_to("Camper", &camper, old).unwrap(), "the old mark stays: case-insensitive, both directories hold it");
830    }
831
832    #[test]
833    fn a_publication_that_returns_after_a_sign_in_marks_the_directory_it_went_to() {
834        // Codex P2s on #3994: a PUT to the OLD account can complete after the
835        // sign-in, in this process or another one sharing this file. It
836        // records the directory it was sent to, so the new one still sees
837        // the key as pending.
838        let (_dir, store) = temp_store();
839        let camper = store.agent_key_ensure("camper", None).unwrap();
840        let (old, new) = ("https://relay#old-account", "https://relay#new-account");
841        // Another process opening the same file, as a second version would.
842        let other = WanIdentityStore::open(&_dir.path().join("wan-identity").join("wan.db")).unwrap();
843        assert!(other.agent_key_mark_published("camper", &camper.public_key, old).unwrap());
844        assert_eq!(store.agent_keys_unpublished(new).unwrap().len(), 1);
845    }
846
847    #[test]
848    fn marks_written_before_v3_count_for_no_directory() {
849        // Upgrade: a key an older binary marked in `published_fp` doesn't say
850        // where it went, so it's published again once, to the current one.
851        let (_dir, store) = temp_store();
852        let camper = store.agent_key_ensure("camper", None).unwrap();
853        {
854            let conn = store.conn.lock().unwrap();
855            conn.execute("UPDATE wan_agent_keys SET published_fp = 'x', published_at = 1 WHERE agent_id = 'camper'", []).unwrap();
856        }
857        assert_eq!(store.agent_keys_unpublished("https://relay#acct").unwrap().len(), 1);
858        assert!(!store.agent_key_is_published_to("camper", &camper, "https://relay#acct").unwrap());
859        assert_eq!(store.schema_version().unwrap(), 3);
860    }
861
862    #[test]
863    fn debug_output_never_contains_a_private_key() {
864        let (_dir, store) = temp_store();
865        let instance = store.instance_ensure("narko").unwrap();
866        let agent = store.agent_key_ensure("camper", None).unwrap();
867        assert!(!format!("{instance:?}").contains(&BASE64.encode(instance.private_key)));
868        assert!(!format!("{agent:?}").contains(&agent.private_key));
869    }
870
871    #[cfg(unix)]
872    #[test]
873    fn the_file_is_owner_only() {
874        use std::os::unix::fs::PermissionsExt;
875        let (dir, _store) = temp_store();
876        let file = dir.path().join("wan-identity").join("wan.db");
877        assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
878    }
879}