1use anyhow::{Result, bail};
8use chrono::Utc;
9use hmac::{Hmac, Mac};
10use sha2::Sha256;
11
12type HmacSha256 = Hmac<Sha256>;
13
14pub 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(×tamp.to_le_bytes());
34 mac.update(nonce.as_bytes());
35 mac.update(payload);
36 Ok(hex::encode(mac.finalize().into_bytes()))
37}
38
39pub 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
57fn 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
68pub 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, }
94 }
95
96 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 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}