Skip to main content

wire/
probe.rs

1//! RFC-004 Tier-1 — connection health probing.
2//!
3//! A `wire ping` sends a probe; the peer's **daemon** auto-responds with a
4//! probe_ack — no LLM / MCP in the loop (RFC-004 AC-HP2 kill criterion). Both
5//! ride the existing `kind=100` heartbeat carrier with a body `t` discriminator
6//! (`probe` / `probe_ack`), NOT a new top-level kind — per the event-kind-carrier
7//! rule (control signals discriminate on a registered generic kind's body).
8//!
9//! Probes are **plaintext** (they carry only a correlation nonce, no secret), so
10//! the receiving daemon reads `t` directly without decrypting. They are
11//! trust-neutral: a probe/ack never mutates a peer's tier or relay state.
12
13use serde_json::{Value, json};
14
15/// The heartbeat carrier kind (registered, special-cased Ephemeral in signing).
16pub const HEARTBEAT_KIND: u64 = 100;
17/// The event `type` string paired with [`HEARTBEAT_KIND`].
18pub const HEARTBEAT_TYPE: &str = "heartbeat";
19
20/// Body of an outbound probe. `nonce` correlates the ack.
21pub fn probe_body(nonce: &str) -> Value {
22    json!({ "t": "probe", "nonce": nonce })
23}
24
25/// Body of a probe_ack answering the probe carrying `nonce`.
26pub fn probe_ack_body(nonce: &str) -> Value {
27    json!({ "t": "probe_ack", "nonce": nonce })
28}
29
30/// If `event` is a kind=100 probe, return its correlation nonce — the signal the
31/// daemon uses to decide whether to auto-respond. An event that is heartbeat-kind
32/// but carries an unknown/other `t` (or a sealed body) returns `None`: it is
33/// simply ignored, cursor advances, no reject (RFC-004 AC-HP4).
34pub fn probe_nonce(event: &Value) -> Option<String> {
35    if event.get("kind").and_then(Value::as_u64) != Some(HEARTBEAT_KIND) {
36        return None;
37    }
38    let body = event.get("body")?;
39    if body.get("t").and_then(Value::as_str) != Some("probe") {
40        return None;
41    }
42    body.get("nonce")
43        .and_then(Value::as_str)
44        .map(str::to_string)
45}
46
47/// True iff `event` is a kind=100 probe_ack carrying `nonce` — the ack a waiting
48/// `wire ping` is looking for.
49pub fn is_probe_ack_for(event: &Value, nonce: &str) -> bool {
50    event.get("kind").and_then(Value::as_u64) == Some(HEARTBEAT_KIND)
51        && event
52            .get("body")
53            .and_then(|b| b.get("t"))
54            .and_then(Value::as_str)
55            == Some("probe_ack")
56        && event
57            .get("body")
58            .and_then(|b| b.get("nonce"))
59            .and_then(Value::as_str)
60            == Some(nonce)
61}
62
63/// Per-peer ack rate gate (RFC-004 AC-HP3 — a 100-probe flood must yield ≤ a
64/// handful of acks, bounded responder CPU). Prunes `times` to the window, then:
65/// `>= max` remaining → refuse the ack (`false`); else record `now` and allow
66/// (`true`). Same sliding-window shape as the relay's intro gate. Pure →
67/// unit-tested. The daemon holds `times` per peer in a process-static map.
68pub fn record_ack_within_rate(times: &mut Vec<u64>, now: u64, window: u64, max: usize) -> bool {
69    times.retain(|t| now.saturating_sub(*t) < window);
70    if times.len() >= max {
71        return false;
72    }
73    times.push(now);
74    true
75}
76
77// ---------- I/O wrappers (build + sign + deliver). The pure helpers above are
78// unit-tested; these do network + key access, exercised by the integration test.
79
80use std::collections::HashMap;
81use std::sync::{Mutex, OnceLock};
82
83/// Process-static per-peer ack-rate state. The daemon is long-lived, so the
84/// AC-HP3 cap must persist across pull cycles (a one-shot `wire pull` can't flood).
85static ACK_RATE: OnceLock<Mutex<HashMap<String, Vec<u64>>>> = OnceLock::new();
86const ACK_MAX_PER_WINDOW: usize = 10;
87const ACK_WINDOW_SECS: u64 = 10;
88
89fn unix_secs() -> u64 {
90    std::time::SystemTime::now()
91        .duration_since(std::time::UNIX_EPOCH)
92        .map(|d| d.as_secs())
93        .unwrap_or(0)
94}
95
96/// Build a signed kind=100 heartbeat event carrying `body` to `peer`. Plaintext
97/// (probe bodies hold only a nonce — no secret). Mirrors the `cmd_send` shape.
98fn build_signed_heartbeat(peer: &str, body: Value) -> anyhow::Result<Value> {
99    use crate::config;
100    let sk_seed = config::read_private_key()?;
101    let card = config::read_agent_card()?;
102    let did = card
103        .get("did")
104        .and_then(Value::as_str)
105        .unwrap_or("")
106        .to_string();
107    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
108    let pk_b64 = card
109        .get("verify_keys")
110        .and_then(Value::as_object)
111        .and_then(|m| m.values().next())
112        .and_then(|v| v.get("key"))
113        .and_then(Value::as_str)
114        .ok_or_else(|| anyhow::anyhow!("agent-card missing verify_keys[*].key"))?;
115    let pk_bytes = crate::signing::b64decode(pk_b64)?;
116    let trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
117    let to_did = crate::trust::resolve_peer_did(&trust, peer);
118    let now = time::OffsetDateTime::now_utc()
119        .format(&time::format_description::well_known::Rfc3339)
120        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
121    let event = json!({
122        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
123        "timestamp": now,
124        "from": did,
125        "to": to_did,
126        "type": HEARTBEAT_TYPE,
127        "kind": HEARTBEAT_KIND,
128        "body": body,
129    });
130    Ok(crate::signing::sign_message_v31(
131        &event, &sk_seed, &pk_bytes, &handle,
132    )?)
133}
134
135/// Send a probe to `peer` (synchronous delivery). The caller then waits for the
136/// matching probe_ack to land in the inbox.
137pub fn send_probe(peer: &str, nonce: &str) -> anyhow::Result<()> {
138    let signed = build_signed_heartbeat(peer, probe_body(nonce))?;
139    crate::send::attempt_deliver(peer, &signed)?;
140    Ok(())
141}
142
143/// Daemon-side auto-respond to verified inbound probes (RFC-004 AC-HP2 — no LLM
144/// in the loop). For each `(peer, nonce)`, build+sign a probe_ack and deliver
145/// it, rate-limited per peer (AC-HP3). Best-effort: one peer's failure never
146/// aborts the rest, and never blocks the pull cycle.
147pub fn respond_to_probes(probes: &[(String, String)]) {
148    if probes.is_empty() {
149        return;
150    }
151    let now = unix_secs();
152    let map = ACK_RATE.get_or_init(|| Mutex::new(HashMap::new()));
153    for (peer, nonce) in probes {
154        let allowed = {
155            let mut g = map.lock().unwrap_or_else(|e| e.into_inner());
156            let times = g.entry(peer.clone()).or_default();
157            record_ack_within_rate(times, now, ACK_WINDOW_SECS, ACK_MAX_PER_WINDOW)
158        };
159        if !allowed {
160            continue;
161        }
162        match build_signed_heartbeat(peer, probe_ack_body(nonce)) {
163            Ok(signed) => {
164                if let Err(e) = crate::send::attempt_deliver(peer, &signed) {
165                    eprintln!("wire: probe_ack to {peer} failed (non-fatal): {e:#}");
166                }
167            }
168            Err(e) => eprintln!("wire: building probe_ack for {peer} failed (non-fatal): {e:#}"),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn probe_nonce_extracts_only_real_probes() {
179        let p = json!({"kind": 100, "type": "heartbeat", "body": {"t": "probe", "nonce": "abc"}});
180        assert_eq!(probe_nonce(&p).as_deref(), Some("abc"));
181        // Wrong kind.
182        assert!(probe_nonce(&json!({"kind": 1, "body": {"t": "probe", "nonce": "x"}})).is_none());
183        // Heartbeat but ack, not probe.
184        assert!(
185            probe_nonce(&json!({"kind": 100, "body": {"t": "probe_ack", "nonce": "x"}})).is_none()
186        );
187        // Heartbeat, unknown t (AC-HP4: ignored, not a probe).
188        assert!(probe_nonce(&json!({"kind": 100, "body": {"t": "weird"}})).is_none());
189        // Sealed body (no plaintext t) → ignored.
190        assert!(probe_nonce(&json!({"kind": 100, "body": {"ct": "..."}})).is_none());
191    }
192
193    #[test]
194    fn is_probe_ack_matches_kind_t_and_nonce() {
195        let a = json!({"kind": 100, "body": {"t": "probe_ack", "nonce": "n1"}});
196        assert!(is_probe_ack_for(&a, "n1"));
197        assert!(!is_probe_ack_for(&a, "n2"), "nonce must match");
198        // A probe (not ack) with the same nonce is not the ack.
199        let p = json!({"kind": 100, "body": {"t": "probe", "nonce": "n1"}});
200        assert!(!is_probe_ack_for(&p, "n1"));
201    }
202
203    #[test]
204    fn ack_rate_gate_caps_a_flood() {
205        // AC-HP3: with max=10 in the window, a 100-probe burst yields exactly 10 acks.
206        let mut times: Vec<u64> = Vec::new();
207        let now = 1_000u64;
208        let mut acked = 0;
209        for _ in 0..100 {
210            if record_ack_within_rate(&mut times, now, 10, 10) {
211                acked += 1;
212            }
213        }
214        assert_eq!(acked, 10, "a 100-probe flood must be capped at 10 acks");
215        // After the window, acks flow again.
216        assert!(record_ack_within_rate(&mut times, now + 11, 10, 10));
217    }
218}