Skip to main content

zeroclaw_runtime/nodes/
transport.rs

1//! Corporate-friendly secure node transport using standard HTTPS + HMAC-SHA256 authentication.
2//!
3//! All inter-node traffic uses plain HTTPS on port 443 — no exotic protocols,
4//! no custom binary framing, no UDP tunneling.  This makes the transport
5//! compatible with corporate proxies, firewalls, and IT audit expectations.
6
7use anyhow::{Result, bail};
8use chrono::Utc;
9use hmac::{Hmac, Mac};
10use sha2::Sha256;
11
12type HmacSha256 = Hmac<Sha256>;
13
14/// Signs a request payload with HMAC-SHA256.
15///
16/// Uses `timestamp` + `nonce` alongside the payload to prevent replay attacks.
17pub fn sign_request(
18    shared_secret: &str,
19    payload: &[u8],
20    timestamp: i64,
21    nonce: &str,
22) -> Result<String> {
23    let mut mac = HmacSha256::new_from_slice(shared_secret.as_bytes()).map_err(|e| {
24        ::zeroclaw_log::record!(
25            ERROR,
26            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
27                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
28                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
29            "node transport: HMAC-SHA256 init rejected shared_secret"
30        );
31        anyhow::Error::msg(format!("HMAC key error: {e}"))
32    })?;
33    mac.update(&timestamp.to_le_bytes());
34    mac.update(nonce.as_bytes());
35    mac.update(payload);
36    Ok(hex::encode(mac.finalize().into_bytes()))
37}
38
39/// Verify a signed request, rejecting stale timestamps for replay protection.
40pub fn verify_request(
41    shared_secret: &str,
42    payload: &[u8],
43    timestamp: i64,
44    nonce: &str,
45    signature: &str,
46    max_age_secs: i64,
47) -> Result<bool> {
48    let now = Utc::now().timestamp();
49    if (now - timestamp).abs() > max_age_secs {
50        bail!("Request timestamp too old or too far in future");
51    }
52
53    let expected = sign_request(shared_secret, payload, timestamp, nonce)?;
54    Ok(constant_time_eq(expected.as_bytes(), signature.as_bytes()))
55}
56
57/// Constant-time comparison to prevent timing attacks.
58fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
59    if a.len() != b.len() {
60        return false;
61    }
62    a.iter()
63        .zip(b.iter())
64        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
65        == 0
66}
67
68// ── Node transport client ───────────────────────────────────────
69
70/// Sends authenticated HTTPS requests to peer nodes.
71///
72/// Every outgoing request carries three custom headers:
73/// - `X-ZeroClaw-Timestamp` — unix epoch seconds
74/// - `X-ZeroClaw-Nonce` — random UUID v4
75/// - `X-ZeroClaw-Signature` — HMAC-SHA256 hex digest
76///
77/// Incoming requests are verified with the same scheme via [`Self::verify_incoming`].
78pub struct NodeTransport {
79    http: reqwest::Client,
80    shared_secret: String,
81    max_request_age_secs: i64,
82}
83
84impl NodeTransport {
85    pub fn new(shared_secret: String) -> Self {
86        Self {
87            http: reqwest::Client::builder()
88                .timeout(std::time::Duration::from_secs(30))
89                .build()
90                .expect("HTTP client build"),
91            shared_secret,
92            max_request_age_secs: 300, // 5 min replay window
93        }
94    }
95
96    /// Send an authenticated request to a peer node.
97    pub async fn send(
98        &self,
99        node_address: &str,
100        endpoint: &str,
101        payload: serde_json::Value,
102    ) -> Result<serde_json::Value> {
103        let body = serde_json::to_vec(&payload)?;
104        let timestamp = Utc::now().timestamp();
105        let nonce = uuid::Uuid::new_v4().to_string();
106        let signature = sign_request(&self.shared_secret, &body, timestamp, &nonce)?;
107
108        let url = format!("https://{node_address}/api/node-control/{endpoint}");
109        let resp = self
110            .http
111            .post(&url)
112            .header("X-ZeroClaw-Timestamp", timestamp.to_string())
113            .header("X-ZeroClaw-Nonce", &nonce)
114            .header("X-ZeroClaw-Signature", &signature)
115            .header("Content-Type", "application/json")
116            .body(body)
117            .send()
118            .await?;
119
120        if !resp.status().is_success() {
121            bail!(
122                "Node request failed: {} {}",
123                resp.status(),
124                resp.text().await.unwrap_or_default()
125            );
126        }
127
128        Ok(resp.json().await?)
129    }
130
131    /// Verify an incoming request from a peer node.
132    pub fn verify_incoming(
133        &self,
134        payload: &[u8],
135        timestamp_header: &str,
136        nonce_header: &str,
137        signature_header: &str,
138    ) -> Result<bool> {
139        let timestamp: i64 = timestamp_header.parse().map_err(|_| {
140            ::zeroclaw_log::record!(
141                WARN,
142                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
143                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
144                    .with_attrs(::serde_json::json!({"header": timestamp_header})),
145                "node transport: invalid timestamp header"
146            );
147            anyhow::Error::msg("Invalid timestamp header")
148        })?;
149        verify_request(
150            &self.shared_secret,
151            payload,
152            timestamp,
153            nonce_header,
154            signature_header,
155            self.max_request_age_secs,
156        )
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    const TEST_SECRET: &str = "test-shared-secret-key";
165
166    #[test]
167    fn sign_request_deterministic() {
168        let sig1 = sign_request(TEST_SECRET, b"hello", 1_700_000_000, "nonce-1").unwrap();
169        let sig2 = sign_request(TEST_SECRET, b"hello", 1_700_000_000, "nonce-1").unwrap();
170        assert_eq!(sig1, sig2, "Same inputs must produce the same signature");
171    }
172
173    #[test]
174    fn verify_request_accepts_valid_signature() {
175        let now = Utc::now().timestamp();
176        let sig = sign_request(TEST_SECRET, b"payload", now, "nonce-a").unwrap();
177        let ok = verify_request(TEST_SECRET, b"payload", now, "nonce-a", &sig, 300).unwrap();
178        assert!(ok, "Valid signature must pass verification");
179    }
180
181    #[test]
182    fn verify_request_rejects_tampered_payload() {
183        let now = Utc::now().timestamp();
184        let sig = sign_request(TEST_SECRET, b"original", now, "nonce-b").unwrap();
185        let ok = verify_request(TEST_SECRET, b"tampered", now, "nonce-b", &sig, 300).unwrap();
186        assert!(!ok, "Tampered payload must fail verification");
187    }
188
189    #[test]
190    fn verify_request_rejects_expired_timestamp() {
191        let old = Utc::now().timestamp() - 600;
192        let sig = sign_request(TEST_SECRET, b"data", old, "nonce-c").unwrap();
193        let result = verify_request(TEST_SECRET, b"data", old, "nonce-c", &sig, 300);
194        assert!(result.is_err(), "Expired timestamp must be rejected");
195    }
196
197    #[test]
198    fn verify_request_rejects_wrong_secret() {
199        let now = Utc::now().timestamp();
200        let sig = sign_request(TEST_SECRET, b"data", now, "nonce-d").unwrap();
201        let ok = verify_request("wrong-secret", b"data", now, "nonce-d", &sig, 300).unwrap();
202        assert!(!ok, "Wrong secret must fail verification");
203    }
204
205    #[test]
206    fn constant_time_eq_correctness() {
207        assert!(constant_time_eq(b"abc", b"abc"));
208        assert!(!constant_time_eq(b"abc", b"abd"));
209        assert!(!constant_time_eq(b"abc", b"ab"));
210        assert!(!constant_time_eq(b"", b"a"));
211        assert!(constant_time_eq(b"", b""));
212    }
213
214    #[test]
215    fn node_transport_construction() {
216        let transport = NodeTransport::new("secret-key".into());
217        assert_eq!(transport.max_request_age_secs, 300);
218    }
219
220    #[test]
221    fn node_transport_verify_incoming_valid() {
222        let transport = NodeTransport::new(TEST_SECRET.into());
223        let now = Utc::now().timestamp();
224        let payload = b"test-body";
225        let nonce = "incoming-nonce";
226        let sig = sign_request(TEST_SECRET, payload, now, nonce).unwrap();
227
228        let ok = transport
229            .verify_incoming(payload, &now.to_string(), nonce, &sig)
230            .unwrap();
231        assert!(ok, "Valid incoming request must pass verification");
232    }
233
234    #[test]
235    fn node_transport_verify_incoming_bad_timestamp_header() {
236        let transport = NodeTransport::new(TEST_SECRET.into());
237        let result = transport.verify_incoming(b"body", "not-a-number", "nonce", "sig");
238        assert!(result.is_err(), "Non-numeric timestamp header must error");
239    }
240
241    #[test]
242    fn sign_request_different_nonce_different_signature() {
243        let sig1 = sign_request(TEST_SECRET, b"data", 1_700_000_000, "nonce-1").unwrap();
244        let sig2 = sign_request(TEST_SECRET, b"data", 1_700_000_000, "nonce-2").unwrap();
245        assert_ne!(
246            sig1, sig2,
247            "Different nonces must produce different signatures"
248        );
249    }
250}