Files
zeroclaw/src/nodes/transport.rs
T
Argenis a8c6363cde feat(nodes): add secure HMAC-SHA256 node transport layer (#3654)
Add a new `nodes` module with HMAC-SHA256 authenticated transport for
secure inter-node communication over standard HTTPS. Includes replay
protection via timestamped nonces and constant-time signature
comparison.

Also adds `NodeTransportConfig` to the config schema and fixes missing
`approval_manager` field in four `ChannelRuntimeContext` test
constructors that failed compilation on latest master.

Original work by @rareba. Rebased on latest master to resolve merge
conflicts (SwarmConfig/SwarmStrategy exports, duplicate MCP validation,
test constructor fields).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:53:47 -04:00

236 lines
7.9 KiB
Rust

//! Corporate-friendly secure node transport using standard HTTPS + HMAC-SHA256 authentication.
//!
//! All inter-node traffic uses plain HTTPS on port 443 — no exotic protocols,
//! no custom binary framing, no UDP tunneling. This makes the transport
//! compatible with corporate proxies, firewalls, and IT audit expectations.
use anyhow::{bail, Result};
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Signs a request payload with HMAC-SHA256.
///
/// Uses `timestamp` + `nonce` alongside the payload to prevent replay attacks.
pub fn sign_request(
shared_secret: &str,
payload: &[u8],
timestamp: i64,
nonce: &str,
) -> Result<String> {
let mut mac = HmacSha256::new_from_slice(shared_secret.as_bytes())
.map_err(|e| anyhow::anyhow!("HMAC key error: {e}"))?;
mac.update(&timestamp.to_le_bytes());
mac.update(nonce.as_bytes());
mac.update(payload);
Ok(hex::encode(mac.finalize().into_bytes()))
}
/// Verify a signed request, rejecting stale timestamps for replay protection.
pub fn verify_request(
shared_secret: &str,
payload: &[u8],
timestamp: i64,
nonce: &str,
signature: &str,
max_age_secs: i64,
) -> Result<bool> {
let now = Utc::now().timestamp();
if (now - timestamp).abs() > max_age_secs {
bail!("Request timestamp too old or too far in future");
}
let expected = sign_request(shared_secret, payload, timestamp, nonce)?;
Ok(constant_time_eq(expected.as_bytes(), signature.as_bytes()))
}
/// Constant-time comparison to prevent timing attacks.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
// ── Node transport client ───────────────────────────────────────
/// Sends authenticated HTTPS requests to peer nodes.
///
/// Every outgoing request carries three custom headers:
/// - `X-ZeroClaw-Timestamp` — unix epoch seconds
/// - `X-ZeroClaw-Nonce` — random UUID v4
/// - `X-ZeroClaw-Signature` — HMAC-SHA256 hex digest
///
/// Incoming requests are verified with the same scheme via [`Self::verify_incoming`].
pub struct NodeTransport {
http: reqwest::Client,
shared_secret: String,
max_request_age_secs: i64,
}
impl NodeTransport {
pub fn new(shared_secret: String) -> Self {
Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("HTTP client build"),
shared_secret,
max_request_age_secs: 300, // 5 min replay window
}
}
/// Send an authenticated request to a peer node.
pub async fn send(
&self,
node_address: &str,
endpoint: &str,
payload: serde_json::Value,
) -> Result<serde_json::Value> {
let body = serde_json::to_vec(&payload)?;
let timestamp = Utc::now().timestamp();
let nonce = uuid::Uuid::new_v4().to_string();
let signature = sign_request(&self.shared_secret, &body, timestamp, &nonce)?;
let url = format!("https://{node_address}/api/node-control/{endpoint}");
let resp = self
.http
.post(&url)
.header("X-ZeroClaw-Timestamp", timestamp.to_string())
.header("X-ZeroClaw-Nonce", &nonce)
.header("X-ZeroClaw-Signature", &signature)
.header("Content-Type", "application/json")
.body(body)
.send()
.await?;
if !resp.status().is_success() {
bail!(
"Node request failed: {} {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
Ok(resp.json().await?)
}
/// Verify an incoming request from a peer node.
pub fn verify_incoming(
&self,
payload: &[u8],
timestamp_header: &str,
nonce_header: &str,
signature_header: &str,
) -> Result<bool> {
let timestamp: i64 = timestamp_header
.parse()
.map_err(|_| anyhow::anyhow!("Invalid timestamp header"))?;
verify_request(
&self.shared_secret,
payload,
timestamp,
nonce_header,
signature_header,
self.max_request_age_secs,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "test-shared-secret-key";
#[test]
fn sign_request_deterministic() {
let sig1 = sign_request(TEST_SECRET, b"hello", 1_700_000_000, "nonce-1").unwrap();
let sig2 = sign_request(TEST_SECRET, b"hello", 1_700_000_000, "nonce-1").unwrap();
assert_eq!(sig1, sig2, "Same inputs must produce the same signature");
}
#[test]
fn verify_request_accepts_valid_signature() {
let now = Utc::now().timestamp();
let sig = sign_request(TEST_SECRET, b"payload", now, "nonce-a").unwrap();
let ok = verify_request(TEST_SECRET, b"payload", now, "nonce-a", &sig, 300).unwrap();
assert!(ok, "Valid signature must pass verification");
}
#[test]
fn verify_request_rejects_tampered_payload() {
let now = Utc::now().timestamp();
let sig = sign_request(TEST_SECRET, b"original", now, "nonce-b").unwrap();
let ok = verify_request(TEST_SECRET, b"tampered", now, "nonce-b", &sig, 300).unwrap();
assert!(!ok, "Tampered payload must fail verification");
}
#[test]
fn verify_request_rejects_expired_timestamp() {
let old = Utc::now().timestamp() - 600;
let sig = sign_request(TEST_SECRET, b"data", old, "nonce-c").unwrap();
let result = verify_request(TEST_SECRET, b"data", old, "nonce-c", &sig, 300);
assert!(result.is_err(), "Expired timestamp must be rejected");
}
#[test]
fn verify_request_rejects_wrong_secret() {
let now = Utc::now().timestamp();
let sig = sign_request(TEST_SECRET, b"data", now, "nonce-d").unwrap();
let ok = verify_request("wrong-secret", b"data", now, "nonce-d", &sig, 300).unwrap();
assert!(!ok, "Wrong secret must fail verification");
}
#[test]
fn constant_time_eq_correctness() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
assert!(!constant_time_eq(b"", b"a"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn node_transport_construction() {
let transport = NodeTransport::new("secret-key".into());
assert_eq!(transport.max_request_age_secs, 300);
}
#[test]
fn node_transport_verify_incoming_valid() {
let transport = NodeTransport::new(TEST_SECRET.into());
let now = Utc::now().timestamp();
let payload = b"test-body";
let nonce = "incoming-nonce";
let sig = sign_request(TEST_SECRET, payload, now, nonce).unwrap();
let ok = transport
.verify_incoming(payload, &now.to_string(), nonce, &sig)
.unwrap();
assert!(ok, "Valid incoming request must pass verification");
}
#[test]
fn node_transport_verify_incoming_bad_timestamp_header() {
let transport = NodeTransport::new(TEST_SECRET.into());
let result = transport.verify_incoming(b"body", "not-a-number", "nonce", "sig");
assert!(result.is_err(), "Non-numeric timestamp header must error");
}
#[test]
fn sign_request_different_nonce_different_signature() {
let sig1 = sign_request(TEST_SECRET, b"data", 1_700_000_000, "nonce-1").unwrap();
let sig2 = sign_request(TEST_SECRET, b"data", 1_700_000_000, "nonce-2").unwrap();
assert_ne!(
sig1, sig2,
"Different nonces must produce different signatures"
);
}
}