Skip to main content

agentmux_srv/muxbus/
wan_verify.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Verify a same-account agent's WAN signature on a cloud-delivered jekt
5//! (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.3–2.6, step D2).
6//!
7//! Runs in `cloud_subscriber::sync_agent_reactive`, before transcript-request
8//! resolution, for each claimed injection. The checks run in the §2.3 order
9//! and every "couldn't check" outcome is `None` — only an active failure
10//! (envelope moved, certificate chain broken, record for something else,
11//! signature bad, replay) is `Some(false)`, the forced-sensitive red flag. A
12//! directory that is down or slow gives `None` and the message is delivered
13//! anyway: never `/reactive/release`, because nothing re-wakes a released row
14//! and it could expire undelivered (§1.2).
15//!
16//! The HTTP entry point never reaches this: an HTTP caller's self-declared
17//! `delivery_tier: "wan"` can't set `wan_verified` (`skip_deserializing`).
18
19use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22use agentmux_common::jekt_sign::{self, WanCarried, WanCheckFailure, WanKeyRecord};
23
24use crate::backend::reactive::types::{WanInstanceInfo, WanInstanceStatus};
25use crate::backend::storage::wan_identity::WanIdentityStore;
26
27/// Directory fetch timeout (§2.3): verification runs per message on the sync
28/// path, so a slow directory must not hold delivery up for long.
29const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
30
31/// How stale an instance's revocation status may be before it is re-checked
32/// on the next message from it (§2.3: hourly).
33const REVOCATION_REFRESH_SECS: i64 = 60 * 60;
34
35/// Global fetch budget: at most this many directory requests per minute, so
36/// a flood of signed messages can't turn this install into a directory
37/// hammer. An exhausted budget reads as "unavailable" → `None`.
38const FETCHES_PER_MINUTE: u32 = 60;
39
40/// The carried fields of one pending row, as the cloud returned them.
41#[derive(Debug, Clone, Default)]
42pub(crate) struct CarriedRow {
43    pub wan_sig: Option<String>,
44    pub wan_msg_id: Option<String>,
45    pub wan_ts_secs: Option<i64>,
46    pub wan_source_agent: Option<String>,
47    pub wan_target_agent: Option<String>,
48    pub wan_source_host: Option<String>,
49    pub wan_source_channel: Option<String>,
50    pub wan_key_fp: Option<String>,
51    pub sender_same_account: Option<bool>,
52}
53
54/// The §2.3 outcome, ready to copy onto the `InjectionRequest`.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub(crate) struct WanVerdict {
57    pub verified: Option<bool>,
58    pub instance: Option<WanInstanceInfo>,
59    pub reason: Option<&'static str>,
60    /// What exactly went wrong behind `reason`, when it has a cause worth
61    /// reading (the directory's HTTP status, a transport or parse error).
62    /// Diagnostics only: logged and audited, never part of the verdict.
63    pub detail: Option<String>,
64}
65
66impl WanVerdict {
67    fn none(reason: &'static str) -> Self {
68        Self { verified: None, instance: None, reason: Some(reason), detail: None }
69    }
70
71    /// "Couldn't check", with the cause. For the receiver's own setup
72    /// failures too, which happen before [`verify`] can run.
73    pub(crate) fn unchecked(reason: &'static str, detail: impl Into<String>) -> Self {
74        Self { verified: None, instance: None, reason: Some(reason), detail: Some(detail.into()) }
75    }
76
77    fn failed(reason: &'static str) -> Self {
78        Self { verified: Some(false), instance: None, reason: Some(reason), detail: None }
79    }
80
81    fn from_failure(f: WanCheckFailure) -> Self {
82        let reason = match f {
83            WanCheckFailure::EnvelopeMismatch => "wan_envelope_mismatch",
84            WanCheckFailure::Stale => "wan_sig_stale",
85            WanCheckFailure::CertInvalid => "wan_cert_invalid",
86            WanCheckFailure::RecordMismatch => "wan_record_mismatch",
87            WanCheckFailure::BadSignature => "wan_sig_invalid",
88        };
89        Self { verified: f.verdict(), instance: None, reason: Some(reason), detail: None }
90    }
91}
92
93/// Where the directory is and how to reach it. Tests point `base_url` at a
94/// stub and bring their own budget.
95pub(crate) struct Directory<'a> {
96    pub base_url: &'a str,
97    pub http: &'a reqwest::Client,
98    pub token: &'a str,
99    pub budget: &'a FetchBudget,
100}
101
102enum Fetched<T> {
103    Found(T),
104    NotFound,
105    /// Why, for the verdict's `detail`: the last attempt's cause.
106    Unavailable(String),
107}
108
109/// A fixed-window request budget ([`FETCHES_PER_MINUTE`]).
110pub(crate) struct FetchBudget {
111    limit: u32,
112    window: Mutex<Option<(Instant, u32)>>,
113}
114
115impl FetchBudget {
116    pub(crate) const fn new(limit: u32) -> Self {
117        Self { limit, window: Mutex::new(None) }
118    }
119
120    fn take(&self) -> bool {
121        let mut window = self.window.lock().unwrap_or_else(|e| e.into_inner());
122        let now = Instant::now();
123        let (start, used) = window.get_or_insert((now, 0));
124        if now.duration_since(*start) >= Duration::from_secs(60) {
125            *start = now;
126            *used = 0;
127        }
128        if *used >= self.limit {
129            return false;
130        }
131        *used += 1;
132        true
133    }
134}
135
136/// The one budget every production verification shares.
137pub(crate) static GLOBAL_BUDGET: FetchBudget = FetchBudget::new(FETCHES_PER_MINUTE);
138
139/// One GET with the §2.3 retry rule: an unavailable directory gets one
140/// immediate retry; a 404 is an answer, not a failure.
141async fn get_json<T: serde::de::DeserializeOwned>(dir: &Directory<'_>, url: &str) -> Fetched<T> {
142    let mut cause = String::new();
143    for _attempt in 0..2 {
144        if !dir.budget.take() {
145            return Fetched::Unavailable("directory fetch budget exhausted".to_string());
146        }
147        let resp = dir
148            .http
149            .get(url)
150            .header("Authorization", format!("Bearer {}", dir.token))
151            .timeout(FETCH_TIMEOUT)
152            .send()
153            .await;
154        match resp {
155            Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => return Fetched::NotFound,
156            Ok(r) if r.status().is_success() => {
157                return match r.json::<T>().await {
158                    Ok(v) => Fetched::Found(v),
159                    // A 200 we can't parse is the cloud's problem; it can't
160                    // be a verification failure on the sender's part.
161                    Err(e) => Fetched::Unavailable(format!("directory record doesn't parse: {e}")),
162                }
163            }
164            // Still "couldn't check", but never silently: a directory that
165            // rejects this install's token turned every WAN jekt
166            // `network-claimed` with nothing in the log to say why. The cause
167            // is returned, not logged per attempt: the caller logs it once
168            // (`cloud_subscriber`'s "wan verify: outcome" at `warn`,
169            // `fetch_revoked` below) and the verdict carries it to the audit.
170            Ok(r) => cause = format!("directory answered {}", r.status()),
171            // `without_url`: the query names the sender's instance and key.
172            Err(e) => cause = format!("directory unreachable: {}", e.without_url()),
173        }
174    }
175    Fetched::Unavailable(cause)
176}
177
178async fn fetch_record(dir: &Directory<'_>, carried: &WanCarried<'_>) -> Fetched<WanKeyRecord> {
179    let mut url = match reqwest::Url::parse(dir.base_url) {
180        Ok(u) => u,
181        Err(e) => return Fetched::Unavailable(format!("bad directory URL: {e}")),
182    };
183    match url.path_segments_mut() {
184        Ok(mut segs) => {
185            segs.pop_if_empty().push("agents").push(&carried.source_agent.to_lowercase()).push("wan-key");
186        }
187        Err(_) => return Fetched::Unavailable("bad directory URL: cannot be a base".to_string()),
188    }
189    url.query_pairs_mut()
190        .append_pair("instance", &carried.source_host.to_lowercase())
191        .append_pair("channel", carried.source_channel)
192        .append_pair("fp", carried.key_fp);
193    get_json(dir, url.as_str()).await
194}
195
196#[derive(serde::Deserialize)]
197struct InstanceStatusResp {
198    revoked: bool,
199}
200
201async fn fetch_revoked(dir: &Directory<'_>, instance_id: &str) -> Option<bool> {
202    let url = format!("{}/wan-instances/{}", dir.base_url.trim_end_matches('/'), instance_id);
203    match get_json::<InstanceStatusResp>(dir, &url).await {
204        Fetched::Found(s) => Some(s.revoked),
205        // An older cloud with no such route: not known to be revoked.
206        Fetched::NotFound => Some(false),
207        // No verdict carries this one, so it is the only place to say it.
208        Fetched::Unavailable(cause) => {
209            tracing::warn!(instance_id, cause = %cause, "wan verify: revocation check couldn't reach the directory");
210            None
211        }
212    }
213}
214
215/// The instance's status (§2.6). This install is approved implicitly. Any
216/// other instance is approved only through the host-gated window, which
217/// ships disabled, so it stays `new`.
218/// Revocation is checked on first sight and at most hourly after.
219async fn instance_status(
220    wan: &WanIdentityStore,
221    own_instance_id: &str,
222    record: &WanKeyRecord,
223    dir: &Directory<'_>,
224    now: i64,
225) -> WanInstanceStatus {
226    let hint = if jekt_sign::is_valid_wan_host_hint(&record.host_hint) { record.host_hint.as_str() } else { "?" };
227    let Ok(mut known) = wan.known_instance_observe(&record.instance_id, hint, now) else {
228        return WanInstanceStatus::New;
229    };
230    let due = known.revocation_checked_at.is_none_or(|t| now.saturating_sub(t) >= REVOCATION_REFRESH_SECS);
231    if known.revoked_at.is_none() && due {
232        if let Some(revoked) = fetch_revoked(dir, &record.instance_id).await {
233            let _ = wan.known_instance_record_revocation_check(&record.instance_id, revoked, now);
234            if revoked {
235                known.revoked_at = Some(now);
236            }
237        }
238    }
239    if known.revoked_at.is_some() {
240        WanInstanceStatus::Revoked
241    } else if record.instance_id == own_instance_id {
242        WanInstanceStatus::Approved
243    } else {
244        WanInstanceStatus::New
245    }
246}
247
248/// The §2.3 table, in order.
249#[allow(clippy::too_many_arguments)]
250pub(crate) async fn verify(
251    wan: &WanIdentityStore,
252    own_instance_id: &str,
253    polled_agent_id: &str,
254    row_source_agent: &str,
255    row: &CarriedRow,
256    message: &str,
257    now: i64,
258    dir: &Directory<'_>,
259) -> WanVerdict {
260    // No signature (or no lookup hint): nothing to check.
261    let (Some(sig), Some(key_fp)) = (row.wan_sig.as_deref(), row.wan_key_fp.as_deref()) else {
262        return WanVerdict::default();
263    };
264    // Cross-account, legacy, or a cloud that doesn't say.
265    if row.sender_same_account != Some(true) {
266        return WanVerdict::none("wan_not_same_account");
267    }
268    let (Some(msg_id), Some(ts_secs), Some(source_agent), Some(target_agent), Some(source_host), Some(source_channel)) = (
269        row.wan_msg_id.as_deref(),
270        row.wan_ts_secs,
271        row.wan_source_agent.as_deref(),
272        row.wan_target_agent.as_deref(),
273        row.wan_source_host.as_deref(),
274        row.wan_source_channel.as_deref(),
275    ) else {
276        // The cloud stores all eight or none; a partial set is not a
277        // signature anyone made.
278        return WanVerdict::none("wan_fields_incomplete");
279    };
280    let carried = WanCarried {
281        sig,
282        msg_id,
283        ts_secs,
284        source_agent,
285        target_agent,
286        source_host,
287        source_channel,
288        key_fp,
289    };
290    if let Err(f) = jekt_sign::check_wan_envelope(&carried, row_source_agent, polled_agent_id, now) {
291        return WanVerdict::from_failure(f);
292    }
293
294    let record = match wan.peer_record_get(source_host, source_agent, source_channel, key_fp) {
295        Ok(Some(cached)) => cached,
296        _ => match fetch_record(dir, &carried).await {
297            Fetched::Found(record) => record,
298            Fetched::NotFound => return WanVerdict::none("wan_key_not_found"),
299            Fetched::Unavailable(cause) => return WanVerdict::unchecked("wan_key_unavailable", cause),
300        },
301    };
302    if let Err(f) = jekt_sign::verify_wan_against_record(&carried, &record, message) {
303        return WanVerdict::from_failure(f);
304    }
305    // Only a record that passed its chain is ever cached.
306    let _ = wan.peer_record_put(&record);
307
308    if wan.seen_sig_contains(&record.instance_id, source_agent, msg_id).unwrap_or(false) {
309        return WanVerdict::failed("wan_sig_replay");
310    }
311
312    let status = instance_status(wan, own_instance_id, &record, dir, now).await;
313    WanVerdict {
314        verified: Some(true),
315        instance: Some(WanInstanceInfo {
316            id: record.instance_id.clone(),
317            label: jekt_sign::wan_instance_display_label(&record.host_hint, &record.instance_id),
318            status,
319        }),
320        reason: None,
321        detail: None,
322    }
323}
324
325/// After a verified message was delivered locally: remember it, so the same
326/// (instance, agent, msgid) is a replay from now on (§2.5). Not before
327/// delivery — the relay's own release-and-redeliver must not look like one.
328pub(crate) fn record_delivered(wan: &WanIdentityStore, verdict: &WanVerdict, row: &CarriedRow, now: i64) {
329    let (Some(instance), Some(agent), Some(msg_id), Some(ts)) = (
330        verdict.instance.as_ref(),
331        row.wan_source_agent.as_deref(),
332        row.wan_msg_id.as_deref(),
333        row.wan_ts_secs,
334    ) else {
335        return;
336    };
337    if verdict.verified != Some(true) {
338        return;
339    }
340    if let Err(e) = wan.seen_sig_record(&instance.id, agent, msg_id, ts.saturating_add(jekt_sign::WAN_SIG_MAX_AGE_SECS), now) {
341        tracing::warn!(error = %e, "wan verify: could not record a delivered signature — a replay of it would verify");
342    }
343}
344
345#[cfg(test)]
346mod tests;