1use std::collections::BTreeMap;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
12
13use crate::crypto;
14use crate::error::Error;
15
16pub const HTTP_TIMESTAMP_WINDOW: u64 = 300;
18
19pub const DEFAULT_EXPIRE_SECONDS: u64 = 300;
21
22#[derive(Debug, Clone)]
28pub struct HttpSignInput<'a> {
29 pub method: &'a str,
31 pub url: &'a str,
33 pub body: &'a [u8],
35 pub timestamp: Option<u64>,
37 pub nonce: Option<String>,
39}
40
41#[derive(Debug, Clone)]
43pub struct HttpSignOutput {
44 pub timestamp: u64,
46 pub nonce: String,
48 pub signature: String,
50}
51
52pub fn build_http_payload(
59 method: &str,
60 url: &str,
61 body: &[u8],
62 timestamp: u64,
63 nonce: &str,
64) -> Result<String, Error> {
65 let canonical_url = canonicalize_url(url)?;
66 let body_hash = crypto::sha256_hex(body);
67
68 Ok(format!(
69 "oaid-http/v1\n{}\n{}\n{}\n{}\n{}",
70 method.to_uppercase(),
71 canonical_url,
72 body_hash,
73 timestamp,
74 nonce,
75 ))
76}
77
78pub fn sign_http(input: &HttpSignInput, key: &SigningKey) -> Result<HttpSignOutput, Error> {
82 let timestamp = input.timestamp.unwrap_or_else(now_unix);
83 let nonce = input
84 .nonce
85 .clone()
86 .unwrap_or_else(crypto::generate_nonce);
87
88 let payload = build_http_payload(input.method, input.url, input.body, timestamp, &nonce)?;
89 let sig = crypto::sign(payload.as_bytes(), key);
90
91 Ok(HttpSignOutput {
92 timestamp,
93 nonce,
94 signature: crypto::base64url_encode(&sig.to_bytes()),
95 })
96}
97
98pub fn verify_http(
104 method: &str,
105 url: &str,
106 body: &[u8],
107 timestamp: u64,
108 nonce: &str,
109 signature_b64: &str,
110 key: &VerifyingKey,
111) -> Result<bool, Error> {
112 let payload = build_http_payload(method, url, body, timestamp, nonce)?;
113 let sig_bytes = crypto::base64url_decode(signature_b64)?;
114 let sig_arr: [u8; 64] = sig_bytes
115 .try_into()
116 .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
117 let sig = Signature::from_bytes(&sig_arr);
118 Ok(crypto::verify(payload.as_bytes(), &sig, key))
119}
120
121#[derive(Debug, Clone)]
127pub struct MsgSignInput<'a> {
128 pub msg_type: &'a str,
130 pub id: &'a str,
132 pub from: &'a str,
134 pub to: &'a [&'a str],
136 pub reference: &'a str,
138 pub timestamp: Option<u64>,
140 pub expires_at: u64,
142 pub body: &'a serde_json::Value,
144}
145
146#[derive(Debug, Clone)]
148pub struct MsgSignOutput {
149 pub timestamp: u64,
151 pub signature: String,
153}
154
155pub fn build_msg_payload(
162 msg_type: &str,
163 id: &str,
164 from: &str,
165 to: &[&str],
166 reference: &str,
167 timestamp: u64,
168 expires_at: u64,
169 body: &serde_json::Value,
170) -> String {
171 let sorted_to = {
172 let mut v: Vec<&str> = to.to_vec();
173 v.sort();
174 v.join(",")
175 };
176
177 let body_hash = crypto::sha256_hex(canonical_json(body).as_bytes());
178
179 format!(
180 "oaid-msg/v1\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
181 msg_type, id, from, sorted_to, reference, timestamp, expires_at, body_hash,
182 )
183}
184
185pub fn sign_msg(input: &MsgSignInput, key: &SigningKey) -> MsgSignOutput {
187 let timestamp = input.timestamp.unwrap_or_else(now_unix);
188 let expires_at = if input.expires_at == 0 {
189 timestamp + DEFAULT_EXPIRE_SECONDS
190 } else {
191 input.expires_at
192 };
193
194 let payload = build_msg_payload(
195 input.msg_type,
196 input.id,
197 input.from,
198 input.to,
199 input.reference,
200 timestamp,
201 expires_at,
202 input.body,
203 );
204
205 let sig = crypto::sign(payload.as_bytes(), key);
206
207 MsgSignOutput {
208 timestamp,
209 signature: crypto::base64url_encode(&sig.to_bytes()),
210 }
211}
212
213pub fn verify_msg(
217 msg_type: &str,
218 id: &str,
219 from: &str,
220 to: &[&str],
221 reference: &str,
222 timestamp: u64,
223 expires_at: u64,
224 body: &serde_json::Value,
225 signature_b64: &str,
226 key: &VerifyingKey,
227) -> Result<bool, Error> {
228 let payload = build_msg_payload(msg_type, id, from, to, reference, timestamp, expires_at, body);
229 let sig_bytes = crypto::base64url_decode(signature_b64)?;
230 let sig_arr: [u8; 64] = sig_bytes
231 .try_into()
232 .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
233 let sig = Signature::from_bytes(&sig_arr);
234 Ok(crypto::verify(payload.as_bytes(), &sig, key))
235}
236
237pub fn canonicalize_url(raw: &str) -> Result<String, Error> {
247 let parsed =
248 url::Url::parse(raw).map_err(|e| Error::InvalidUrl(format!("{e}: {raw}")))?;
249
250 let scheme = parsed.scheme();
251 let host = parsed
252 .host_str()
253 .ok_or_else(|| Error::InvalidUrl(format!("URL has no host: {raw}")))?
254 .to_ascii_lowercase();
255 let port_suffix = match parsed.port() {
256 Some(p) => format!(":{p}"),
257 None => String::new(),
258 };
259 let path = parsed.path();
260
261 let query_string = {
263 let mut pairs: Vec<(String, String)> = parsed
264 .query_pairs()
265 .map(|(k, v)| (k.into_owned(), v.into_owned()))
266 .collect();
267
268 pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
269
270 if pairs.is_empty() {
271 String::new()
272 } else {
273 let parts: Vec<String> = pairs
274 .iter()
275 .map(|(k, v)| format!("{k}={v}"))
276 .collect();
277 format!("?{}", parts.join("&"))
278 }
279 };
280
281 Ok(format!("{scheme}://{host}{port_suffix}{path}{query_string}"))
282}
283
284pub fn canonical_json(value: &serde_json::Value) -> String {
288 match value {
289 serde_json::Value::Object(map) => {
290 let mut sorted: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
291 for (k, v) in map {
292 sorted.insert(k.as_str(), v);
293 }
294 let entries: Vec<String> = sorted
295 .iter()
296 .map(|(k, v)| format!("\"{}\":{}", k, canonical_json(v)))
297 .collect();
298 format!("{{{}}}", entries.join(","))
299 }
300 serde_json::Value::Array(arr) => {
301 let items: Vec<String> = arr.iter().map(canonical_json).collect();
302 format!("[{}]", items.join(","))
303 }
304 _ => serde_json::to_string(value).unwrap_or_default(),
305 }
306}
307
308pub fn sign_agent_auth(
313 did: &str,
314 key: &SigningKey,
315) -> std::collections::HashMap<String, String> {
316 let timestamp = now_unix();
317 let nonce = crypto::generate_nonce();
318
319 let payload = format!("{}\n{}\n{}", did, timestamp, nonce);
320 let sig = crypto::sign(payload.as_bytes(), key);
321
322 let mut headers = std::collections::HashMap::new();
323 headers.insert("X-Agent-DID".to_string(), did.to_string());
324 headers.insert("X-Agent-Timestamp".to_string(), timestamp.to_string());
325 headers.insert("X-Agent-Nonce".to_string(), nonce);
326 headers.insert(
327 "X-Agent-Signature".to_string(),
328 crypto::base64url_encode(&sig.to_bytes()),
329 );
330 headers
331}
332
333pub fn verify_agent_auth(
339 did: &str,
340 timestamp: u64,
341 nonce: &str,
342 signature_b64: &str,
343 key: &VerifyingKey,
344) -> Result<bool, Error> {
345 let payload = format!("{}\n{}\n{}", did, timestamp, nonce);
346 let sig_bytes = crypto::base64url_decode(signature_b64)?;
347 let sig_arr: [u8; 64] = sig_bytes
348 .try_into()
349 .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
350 let sig = Signature::from_bytes(&sig_arr);
351 Ok(crypto::verify(payload.as_bytes(), &sig, key))
352}
353
354fn now_unix() -> u64 {
356 SystemTime::now()
357 .duration_since(UNIX_EPOCH)
358 .expect("system clock before Unix epoch")
359 .as_secs()
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn canonical_url_basic() {
368 let result = canonicalize_url("https://API.Example.com/v1/agents").unwrap();
369 assert_eq!(result, "https://api.example.com/v1/agents");
370 }
371
372 #[test]
373 fn canonical_url_sorted_query() {
374 let result =
375 canonicalize_url("https://api.example.com/v1/agents?offset=0&limit=10").unwrap();
376 assert_eq!(
377 result,
378 "https://api.example.com/v1/agents?limit=10&offset=0"
379 );
380 }
381
382 #[test]
383 fn canonical_url_no_query() {
384 let result = canonicalize_url("https://api.example.com/path").unwrap();
385 assert!(!result.contains('?'));
386 }
387
388 #[test]
389 fn canonical_url_strips_fragment() {
390 let result =
391 canonicalize_url("https://api.example.com/path?a=1#section").unwrap();
392 assert!(!result.contains('#'));
393 assert_eq!(result, "https://api.example.com/path?a=1");
394 }
395
396 #[test]
397 fn canonical_url_with_port() {
398 let result = canonicalize_url("https://localhost:8080/api").unwrap();
399 assert_eq!(result, "https://localhost:8080/api");
400 }
401
402 #[test]
403 fn canonical_url_duplicate_query_params() {
404 let result =
405 canonicalize_url("https://api.example.com/v1/agents?tag=b&tag=a&limit=10").unwrap();
406 assert_eq!(
407 result,
408 "https://api.example.com/v1/agents?limit=10&tag=a&tag=b"
409 );
410 }
411
412 #[test]
413 fn canonical_json_sorted_keys() {
414 let val: serde_json::Value =
415 serde_json::from_str(r#"{"z":1,"a":"hello","m":[3,2,1]}"#).unwrap();
416 let result = canonical_json(&val);
417 assert_eq!(result, r#"{"a":"hello","m":[3,2,1],"z":1}"#);
418 }
419
420 #[test]
421 fn canonical_json_nested() {
422 let val: serde_json::Value =
423 serde_json::from_str(r#"{"b":{"d":4,"c":3},"a":1}"#).unwrap();
424 let result = canonical_json(&val);
425 assert_eq!(result, r#"{"a":1,"b":{"c":3,"d":4}}"#);
426 }
427
428 #[test]
429 fn canonical_json_empty_object() {
430 let val: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
431 assert_eq!(canonical_json(&val), "{}");
432 }
433
434 #[test]
435 fn http_sign_verify_roundtrip() {
436 let (sk, vk) = crypto::generate_keypair();
437
438 let input = HttpSignInput {
439 method: "POST",
440 url: "https://api.example.com/v1/agents",
441 body: b"{\"name\":\"bot\"}",
442 timestamp: Some(1708123456),
443 nonce: Some("a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8".to_string()),
444 };
445
446 let output = sign_http(&input, &sk).unwrap();
447
448 let valid = verify_http(
449 input.method,
450 input.url,
451 input.body,
452 output.timestamp,
453 &output.nonce,
454 &output.signature,
455 &vk,
456 )
457 .unwrap();
458 assert!(valid);
459 }
460
461 #[test]
462 fn http_payload_format() {
463 let payload = build_http_payload(
464 "post",
465 "https://API.Example.com/v1/agents?offset=0&limit=10",
466 b"",
467 1708123456,
468 "deadbeef00000000deadbeef00000000",
469 )
470 .unwrap();
471
472 let lines: Vec<&str> = payload.split('\n').collect();
473 assert_eq!(lines[0], "oaid-http/v1");
474 assert_eq!(lines[1], "POST");
475 assert_eq!(
476 lines[2],
477 "https://api.example.com/v1/agents?limit=10&offset=0"
478 );
479 assert_eq!(
481 lines[3],
482 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
483 );
484 assert_eq!(lines[4], "1708123456");
485 assert_eq!(lines[5], "deadbeef00000000deadbeef00000000");
486 }
487
488 #[test]
489 fn msg_sign_verify_roundtrip() {
490 let (sk, vk) = crypto::generate_keypair();
491
492 let body = serde_json::json!({"proposal": "do-something", "quorum": 3});
493 let input = MsgSignInput {
494 msg_type: "consensus/ballot",
495 id: "019504a0-0000-7000-8000-000000000001",
496 from: "did:oaid:base:0x0000000000000000000000000000000000000001",
497 to: &[
498 "did:oaid:base:0x0000000000000000000000000000000000000003",
499 "did:oaid:base:0x0000000000000000000000000000000000000002",
500 ],
501 reference: "",
502 timestamp: Some(1708123456),
503 expires_at: 0,
504 body: &body,
505 };
506
507 let output = sign_msg(&input, &sk);
508
509 let expected_expires = output.timestamp + DEFAULT_EXPIRE_SECONDS;
511 let valid = verify_msg(
512 input.msg_type,
513 input.id,
514 input.from,
515 input.to,
516 input.reference,
517 output.timestamp,
518 expected_expires,
519 input.body,
520 &output.signature,
521 &vk,
522 )
523 .unwrap();
524 assert!(valid);
525 }
526
527 #[test]
528 fn msg_payload_sorted_to() {
529 let body = serde_json::json!({});
530 let payload = build_msg_payload(
531 "test",
532 "id1",
533 "from",
534 &["did:oaid:base:0xbbbb", "did:oaid:base:0xaaaa"],
535 "",
536 100,
537 0,
538 &body,
539 );
540 let lines: Vec<&str> = payload.split('\n').collect();
541 assert_eq!(lines[4], "did:oaid:base:0xaaaa,did:oaid:base:0xbbbb");
543 }
544
545 #[test]
546 fn msg_payload_empty_to() {
547 let body = serde_json::json!({});
548 let payload = build_msg_payload("test", "id1", "from", &[], "", 100, 0, &body);
549 let lines: Vec<&str> = payload.split('\n').collect();
550 assert_eq!(lines[4], ""); }
552
553 #[test]
554 fn agent_auth_sign_verify_roundtrip() {
555 let (sk, vk) = crypto::generate_keypair();
556 let did = "did:oaid:base:0x0000000000000000000000000000000000000001";
557
558 let headers = sign_agent_auth(did, &sk);
559 assert_eq!(headers.get("X-Agent-DID").unwrap(), did);
560
561 let ts: u64 = headers.get("X-Agent-Timestamp").unwrap().parse().unwrap();
562 let nonce = headers.get("X-Agent-Nonce").unwrap();
563 let sig = headers.get("X-Agent-Signature").unwrap();
564
565 let valid = verify_agent_auth(did, ts, nonce, sig, &vk).unwrap();
566 assert!(valid);
567 }
568
569 #[test]
570 fn http_verify_wrong_body_fails() {
571 let (sk, vk) = crypto::generate_keypair();
572
573 let input = HttpSignInput {
574 method: "POST",
575 url: "https://api.example.com/test",
576 body: b"original",
577 timestamp: Some(1000),
578 nonce: Some("0".repeat(32)),
579 };
580
581 let output = sign_http(&input, &sk).unwrap();
582
583 let valid = verify_http(
585 "POST",
586 "https://api.example.com/test",
587 b"tampered",
588 output.timestamp,
589 &output.nonce,
590 &output.signature,
591 &vk,
592 )
593 .unwrap();
594 assert!(!valid);
595 }
596}