1use crate::{Error, Result};
23
24pub const PROTOCOL_VERSION_V2: &str = "v2";
26
27pub const MAX_CLOCK_SKEW_SECS_V2: i64 = 60;
30
31pub const MIN_NONCE_LEN: usize = 32;
34
35pub const MAX_NONCE_LEN: usize = 128;
37
38pub fn is_within_clock_skew_v2(now_unix: i64, issued_at_unix: i64) -> bool {
41 let diff = (now_unix as i128).saturating_sub(issued_at_unix as i128);
42 diff.unsigned_abs() <= MAX_CLOCK_SKEW_SECS_V2 as u128
43}
44
45pub fn registration_challenge_bytes_v2(
61 public_key_hex: &str,
62 org: &str,
63 name: &str,
64 nonce: &str,
65 issued_at_unix: i64,
66) -> Result<Vec<u8>> {
67 validate_ascii_line(public_key_hex, "public_key_hex")?;
68 validate_ascii_line(org, "org")?;
69 validate_ascii_line(name, "name")?;
70 validate_nonce(nonce)?;
71
72 let msg = format!(
73 "aex-register:{version}\npub={pub}\norg={org}\nname={name}\nnonce={nonce}\nts={ts}",
74 version = PROTOCOL_VERSION_V2,
75 pub = public_key_hex,
76 org = org,
77 name = name,
78 nonce = nonce,
79 ts = issued_at_unix,
80 );
81 Ok(msg.into_bytes())
82}
83
84pub fn transfer_intent_bytes_v2(
102 sender_agent_id: &str,
103 recipient: &str,
104 size_bytes: u64,
105 declared_mime: &str,
106 filename: &str,
107 nonce: &str,
108 issued_at_unix: i64,
109) -> Result<Vec<u8>> {
110 validate_ascii_line(sender_agent_id, "sender_agent_id")?;
111 validate_ascii_line(recipient, "recipient")?;
112 validate_ascii_line_opt(declared_mime, "declared_mime")?;
113 validate_ascii_line_opt(filename, "filename")?;
114 validate_nonce(nonce)?;
115
116 let msg = format!(
117 "aex-transfer-intent:{version}\nsender={sender}\nrecipient={recipient}\nsize={size}\nmime={mime}\nfilename={filename}\nnonce={nonce}\nts={ts}",
118 version = PROTOCOL_VERSION_V2,
119 sender = sender_agent_id,
120 recipient = recipient,
121 size = size_bytes,
122 mime = declared_mime,
123 filename = filename,
124 nonce = nonce,
125 ts = issued_at_unix,
126 );
127 Ok(msg.into_bytes())
128}
129
130pub fn data_ticket_bytes_v2(
142 transfer_id: &str,
143 recipient_agent_id: &str,
144 data_plane_url: &str,
145 expires_unix: i64,
146 nonce: &str,
147) -> Result<Vec<u8>> {
148 validate_ascii_line(transfer_id, "transfer_id")?;
149 validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
150 validate_ascii_line(data_plane_url, "data_plane_url")?;
151 validate_nonce(nonce)?;
152
153 let msg = format!(
154 "aex-data-ticket:{version}\ntransfer={tx}\nrecipient={rec}\ndata_plane={dp}\nexpires={exp}\nnonce={nonce}",
155 version = PROTOCOL_VERSION_V2,
156 tx = transfer_id,
157 rec = recipient_agent_id,
158 dp = data_plane_url,
159 exp = expires_unix,
160 nonce = nonce,
161 );
162 Ok(msg.into_bytes())
163}
164
165pub fn rotate_key_challenge_bytes_v2(
177 agent_id: &str,
178 old_public_key_hex: &str,
179 new_public_key_hex: &str,
180 nonce: &str,
181 issued_at_unix: i64,
182) -> Result<Vec<u8>> {
183 validate_ascii_line(agent_id, "agent_id")?;
184 validate_ascii_line(old_public_key_hex, "old_public_key_hex")?;
185 validate_ascii_line(new_public_key_hex, "new_public_key_hex")?;
186 validate_nonce(nonce)?;
187
188 if old_public_key_hex == new_public_key_hex {
189 return Err(Error::Internal(
190 "old_public_key_hex and new_public_key_hex must differ".into(),
191 ));
192 }
193
194 let msg = format!(
195 "aex-rotate-key:{version}\nagent={agent}\nold_pub={old}\nnew_pub={new}\nnonce={nonce}\nts={ts}",
196 version = PROTOCOL_VERSION_V2,
197 agent = agent_id,
198 old = old_public_key_hex,
199 new = new_public_key_hex,
200 nonce = nonce,
201 ts = issued_at_unix,
202 );
203 Ok(msg.into_bytes())
204}
205
206pub fn transfer_receipt_bytes_v2(
209 recipient_agent_id: &str,
210 transfer_id: &str,
211 action: &str,
212 nonce: &str,
213 issued_at_unix: i64,
214) -> Result<Vec<u8>> {
215 validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
216 validate_ascii_line(transfer_id, "transfer_id")?;
217 validate_ascii_line(action, "action")?;
218 validate_nonce(nonce)?;
219
220 if !matches!(action, "download" | "ack" | "inbox" | "request_ticket") {
221 return Err(Error::Internal(format!(
222 "action must be 'download', 'ack', 'inbox' or 'request_ticket', got {}",
223 action
224 )));
225 }
226
227 let msg = format!(
228 "aex-transfer-receipt:{version}\nrecipient={rec}\ntransfer={tx}\naction={act}\nnonce={nonce}\nts={ts}",
229 version = PROTOCOL_VERSION_V2,
230 rec = recipient_agent_id,
231 tx = transfer_id,
232 act = action,
233 nonce = nonce,
234 ts = issued_at_unix,
235 );
236 Ok(msg.into_bytes())
237}
238
239pub fn decision_request_bytes_v2(
264 recipient_agent_id: &str,
265 transfer_id: &str,
266 decision_id: &str,
267 eta_seconds: u64,
268 nonce: &str,
269 issued_at_unix: i64,
270) -> Result<Vec<u8>> {
271 validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
272 validate_ascii_line(transfer_id, "transfer_id")?;
273 validate_ascii_line(decision_id, "decision_id")?;
274 validate_nonce(nonce)?;
275
276 let msg = format!(
277 "aex-decision-request:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\neta_secs={eta}\nnonce={nonce}\nts={ts}",
278 version = PROTOCOL_VERSION_V2,
279 rec = recipient_agent_id,
280 tx = transfer_id,
281 dec = decision_id,
282 eta = eta_seconds,
283 nonce = nonce,
284 ts = issued_at_unix,
285 );
286 Ok(msg.into_bytes())
287}
288
289pub fn decision_response_bytes_v2(
317 recipient_agent_id: &str,
318 transfer_id: &str,
319 decision_id: &str,
320 outcome: &str,
321 reason: &str,
322 nonce: &str,
323 issued_at_unix: i64,
324) -> Result<Vec<u8>> {
325 validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
326 validate_ascii_line(transfer_id, "transfer_id")?;
327 validate_ascii_line(decision_id, "decision_id")?;
328 validate_ascii_line(outcome, "outcome")?;
329 validate_ascii_line_opt(reason, "reason")?;
330 validate_nonce(nonce)?;
331
332 if !matches!(outcome, "accepted" | "rejected") {
333 return Err(Error::Internal(format!(
334 "outcome must be 'accepted' or 'rejected', got {}",
335 outcome
336 )));
337 }
338
339 let msg = format!(
340 "aex-decision-response:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\noutcome={out}\nreason={reason}\nnonce={nonce}\nts={ts}",
341 version = PROTOCOL_VERSION_V2,
342 rec = recipient_agent_id,
343 tx = transfer_id,
344 dec = decision_id,
345 out = outcome,
346 reason = reason,
347 nonce = nonce,
348 ts = issued_at_unix,
349 );
350 Ok(msg.into_bytes())
351}
352
353fn validate_ascii_line(s: &str, field: &str) -> Result<()> {
357 if s.is_empty() {
358 return Err(Error::Internal(format!("{} is empty", field)));
359 }
360 for (i, c) in s.chars().enumerate() {
361 if !c.is_ascii() || c == '\n' || c == '\r' || c == '\0' {
362 return Err(Error::Internal(format!(
363 "{} has invalid char at {}: {:?}",
364 field, i, c
365 )));
366 }
367 }
368 Ok(())
369}
370
371fn validate_ascii_line_opt(s: &str, field: &str) -> Result<()> {
372 if s.is_empty() {
373 return Ok(());
374 }
375 validate_ascii_line(s, field)
376}
377
378fn validate_nonce(nonce: &str) -> Result<()> {
379 if nonce.len() < MIN_NONCE_LEN || nonce.len() > MAX_NONCE_LEN {
380 return Err(Error::Internal(format!(
381 "nonce length {} outside [{}, {}]",
382 nonce.len(),
383 MIN_NONCE_LEN,
384 MAX_NONCE_LEN
385 )));
386 }
387 if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
388 return Err(Error::Internal("nonce must be hex".into()));
389 }
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 const NONCE: &str = "0123456789abcdef0123456789abcdef";
398
399 #[test]
400 fn v2_register_canonical_bytes_stable() {
401 let bytes =
402 registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
403 .unwrap();
404 let expected = "aex-register:v2\npub=aabbcc\norg=acme\nname=alice\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
405 assert_eq!(bytes, expected.as_bytes());
406 }
407
408 #[test]
409 fn v2_transfer_intent_uses_did_uri() {
410 let bytes = transfer_intent_bytes_v2(
411 "did:web:acme.com#agent-vendite",
412 "did:web:beta-corp.com#acquisti",
413 12345,
414 "application/pdf",
415 "invoice.pdf",
416 NONCE,
417 1_700_000_000,
418 )
419 .unwrap();
420 let expected = "aex-transfer-intent:v2\nsender=did:web:acme.com#agent-vendite\nrecipient=did:web:beta-corp.com#acquisti\nsize=12345\nmime=application/pdf\nfilename=invoice.pdf\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
421 assert_eq!(bytes, expected.as_bytes());
422 }
423
424 #[test]
425 fn v2_transfer_intent_with_legacy_spize_id() {
426 let bytes = transfer_intent_bytes_v2(
428 "spize:acme/alice:aabbcc",
429 "did:ethr:0x14a34:0xabc",
430 100,
431 "",
432 "",
433 NONCE,
434 1_700_000_000,
435 )
436 .unwrap();
437 let s = std::str::from_utf8(&bytes).unwrap();
438 assert!(s.starts_with("aex-transfer-intent:v2\n"));
439 assert!(s.contains("sender=spize:acme/alice:aabbcc\n"));
440 assert!(s.contains("recipient=did:ethr:0x14a34:0xabc\n"));
441 }
442
443 #[test]
444 fn v2_data_ticket_stable() {
445 let bytes = data_ticket_bytes_v2(
446 "tx_abc123",
447 "did:web:acme.com#bob",
448 "https://data.acme.com",
449 1_700_000_100,
450 NONCE,
451 )
452 .unwrap();
453 let expected = "aex-data-ticket:v2\ntransfer=tx_abc123\nrecipient=did:web:acme.com#bob\ndata_plane=https://data.acme.com\nexpires=1700000100\nnonce=0123456789abcdef0123456789abcdef";
454 assert_eq!(bytes, expected.as_bytes());
455 }
456
457 #[test]
458 fn v2_rotate_key_stable() {
459 let old = "1".repeat(64);
460 let new = "2".repeat(64);
461 let bytes = rotate_key_challenge_bytes_v2(
462 "did:spize:acme/alice#aabbcc",
463 &old,
464 &new,
465 NONCE,
466 1_700_000_000,
467 )
468 .unwrap();
469 let s = std::str::from_utf8(&bytes).unwrap();
470 assert!(s.starts_with("aex-rotate-key:v2\n"));
471 assert!(s.contains("agent=did:spize:acme/alice#aabbcc\n"));
472 }
473
474 #[test]
475 fn v2_receipt_stable() {
476 let bytes = transfer_receipt_bytes_v2(
477 "did:web:beta-corp.com#acquisti",
478 "tx_abc123",
479 "ack",
480 NONCE,
481 1_700_000_000,
482 )
483 .unwrap();
484 let expected = "aex-transfer-receipt:v2\nrecipient=did:web:beta-corp.com#acquisti\ntransfer=tx_abc123\naction=ack\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
485 assert_eq!(bytes, expected.as_bytes());
486 }
487
488 #[test]
489 fn v2_clock_skew_60s_window() {
490 let now = 1_700_000_000_i64;
491 assert!(is_within_clock_skew_v2(now, now));
492 assert!(is_within_clock_skew_v2(now, now - 60));
493 assert!(is_within_clock_skew_v2(now, now + 60));
494 assert!(!is_within_clock_skew_v2(now, now - 61));
495 assert!(!is_within_clock_skew_v2(now, now + 61));
496 }
497
498 #[test]
499 fn v2_clock_skew_extreme_inputs_do_not_panic() {
500 let now = 1_700_000_000_i64;
501 assert!(!is_within_clock_skew_v2(now, i64::MIN));
502 assert!(!is_within_clock_skew_v2(now, i64::MAX));
503 assert!(!is_within_clock_skew_v2(i64::MAX, i64::MIN));
504 }
505
506 #[test]
507 fn v2_newline_in_field_rejected() {
508 let err = registration_challenge_bytes_v2("aa", "ac\nme", "alice", NONCE, 100).unwrap_err();
509 assert!(matches!(err, Error::Internal(_)));
510 }
511
512 #[test]
513 fn v2_non_ascii_field_rejected() {
514 let err = registration_challenge_bytes_v2("aa", "acmè", "alice", NONCE, 100).unwrap_err();
515 assert!(matches!(err, Error::Internal(_)));
516 }
517
518 #[test]
519 fn v2_short_nonce_rejected() {
520 let err =
521 registration_challenge_bytes_v2("aa", "acme", "alice", "deadbeef", 100).unwrap_err();
522 assert!(matches!(err, Error::Internal(_)));
523 }
524
525 #[test]
526 fn v2_non_hex_nonce_rejected() {
527 let err = registration_challenge_bytes_v2("aa", "acme", "alice", &"z".repeat(32), 100)
528 .unwrap_err();
529 assert!(matches!(err, Error::Internal(_)));
530 }
531
532 #[test]
533 fn v2_rotate_key_rejects_same_old_and_new() {
534 let same = "a".repeat(64);
535 let err = rotate_key_challenge_bytes_v2(
536 "did:spize:acme/alice#aabbcc",
537 &same,
538 &same,
539 NONCE,
540 1_700_000_000,
541 )
542 .unwrap_err();
543 assert!(matches!(err, Error::Internal(_)));
544 }
545
546 #[test]
547 fn v2_receipt_rejects_bad_action() {
548 let err =
549 transfer_receipt_bytes_v2("did:web:beta-corp.com#bob", "tx_abc", "overwrite", NONCE, 1)
550 .unwrap_err();
551 assert!(matches!(err, Error::Internal(_)));
552 }
553
554 #[test]
555 fn v2_data_ticket_rejects_newline_url() {
556 let err = data_ticket_bytes_v2(
557 "tx_abc",
558 "did:web:acme.com#bob",
559 "https://evil.test\nspoof",
560 1,
561 NONCE,
562 )
563 .unwrap_err();
564 assert!(matches!(err, Error::Internal(_)));
565 }
566
567 #[test]
568 fn v2_decision_request_stable() {
569 let bytes = decision_request_bytes_v2(
570 "did:web:acme.com#agent-vendite",
571 "tx_abc123",
572 "dec_0001",
573 86_400,
574 NONCE,
575 1_700_000_000,
576 )
577 .unwrap();
578 let expected = "aex-decision-request:v2\nrecipient=did:web:acme.com#agent-vendite\ntransfer=tx_abc123\ndecision=dec_0001\neta_secs=86400\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
579 assert_eq!(bytes, expected.as_bytes());
580 }
581
582 #[test]
583 fn v2_decision_response_accepted_stable() {
584 let bytes = decision_response_bytes_v2(
585 "did:web:acme.com#agent-vendite",
586 "tx_abc123",
587 "dec_0001",
588 "accepted",
589 "",
590 NONCE,
591 1_700_000_000,
592 )
593 .unwrap();
594 let expected = "aex-decision-response:v2\nrecipient=did:web:acme.com#agent-vendite\ntransfer=tx_abc123\ndecision=dec_0001\noutcome=accepted\nreason=\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
595 assert_eq!(bytes, expected.as_bytes());
596 }
597
598 #[test]
599 fn v2_decision_response_rejected_with_reason_stable() {
600 let bytes = decision_response_bytes_v2(
601 "did:web:acme.com#agent-vendite",
602 "tx_abc123",
603 "dec_0001",
604 "rejected",
605 "operator declined: budget exceeded",
606 NONCE,
607 1_700_000_000,
608 )
609 .unwrap();
610 let s = std::str::from_utf8(&bytes).unwrap();
611 assert!(s.starts_with("aex-decision-response:v2\n"));
612 assert!(s.contains("outcome=rejected\n"));
613 assert!(s.contains("reason=operator declined: budget exceeded\n"));
614 }
615
616 #[test]
617 fn v2_decision_response_rejects_bad_outcome() {
618 let err = decision_response_bytes_v2(
619 "did:web:acme.com#agent-vendite",
620 "tx_abc123",
621 "dec_0001",
622 "maybe",
623 "",
624 NONCE,
625 1_700_000_000,
626 )
627 .unwrap_err();
628 assert!(matches!(err, Error::Internal(_)));
629 }
630
631 #[test]
632 fn v2_decision_request_rejects_newline_in_fields() {
633 let err = decision_request_bytes_v2(
634 "did:web:acme.com#agent-vendite",
635 "tx_abc\n123",
636 "dec_0001",
637 60,
638 NONCE,
639 1_700_000_000,
640 )
641 .unwrap_err();
642 assert!(matches!(err, Error::Internal(_)));
643 }
644
645 #[test]
646 fn v2_decision_messages_distinguish_request_from_response() {
647 let req = decision_request_bytes_v2(
651 "did:web:acme.com#x",
652 "tx_1",
653 "dec_1",
654 60,
655 NONCE,
656 1_700_000_000,
657 )
658 .unwrap();
659 let resp = decision_response_bytes_v2(
660 "did:web:acme.com#x",
661 "tx_1",
662 "dec_1",
663 "accepted",
664 "",
665 NONCE,
666 1_700_000_000,
667 )
668 .unwrap();
669 assert_ne!(req, resp);
670 assert!(std::str::from_utf8(&req)
671 .unwrap()
672 .starts_with("aex-decision-request:v2\n"));
673 assert!(std::str::from_utf8(&resp)
674 .unwrap()
675 .starts_with("aex-decision-response:v2\n"));
676 }
677
678 #[test]
679 fn v2_prefix_differs_from_v1_for_identical_inputs() {
680 let v1 = crate::wire::registration_challenge_bytes(
684 "aabbcc",
685 "acme",
686 "alice",
687 NONCE,
688 1_700_000_000,
689 )
690 .unwrap();
691 let v2 = registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
692 .unwrap();
693 assert_ne!(v1, v2);
694 assert!(std::str::from_utf8(&v1).unwrap().starts_with("spize-"));
696 assert!(std::str::from_utf8(&v2).unwrap().starts_with("aex-"));
697 }
698}