1use 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
46const 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#[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 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#[derive(Clone, PartialEq, Eq)]
157pub struct WanAgentKey {
158 pub public_key: String,
159 pub private_key: String,
160 pub imported: bool,
161 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 pub fn public_key_bytes(&self) -> Option<[u8; 32]> {
180 decode_32(&self.public_key)
181 }
182
183}
184
185#[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
199pub 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
210pub fn resolve_wan_identity_path() -> Option<PathBuf> {
214 agentmux_common::DataPaths::from_env().map(|paths| paths.wan_identity_dir().join("wan.db"))
215}
216
217pub 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
250fn 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#[cfg(not(unix))]
276fn restrict_permissions(_dir: &Path, _file: &Path) {}
277
278impl WanIdentityStore {
279 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 retry_while_busy(|| {
295 conn.pragma_update(None, "journal_mode", "WAL")?;
296 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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 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 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 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 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 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 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 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 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 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}