use crate::{Error, Result};
pub const PROTOCOL_VERSION_V2: &str = "v2";
pub const MAX_CLOCK_SKEW_SECS_V2: i64 = 60;
pub const MIN_NONCE_LEN: usize = 32;
pub const MAX_NONCE_LEN: usize = 128;
pub fn is_within_clock_skew_v2(now_unix: i64, issued_at_unix: i64) -> bool {
let diff = (now_unix as i128).saturating_sub(issued_at_unix as i128);
diff.unsigned_abs() <= MAX_CLOCK_SKEW_SECS_V2 as u128
}
pub fn registration_challenge_bytes_v2(
public_key_hex: &str,
org: &str,
name: &str,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(public_key_hex, "public_key_hex")?;
validate_ascii_line(org, "org")?;
validate_ascii_line(name, "name")?;
validate_nonce(nonce)?;
let msg = format!(
"aex-register:{version}\npub={pub}\norg={org}\nname={name}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
pub = public_key_hex,
org = org,
name = name,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
pub fn transfer_intent_bytes_v2(
sender_agent_id: &str,
recipient: &str,
size_bytes: u64,
declared_mime: &str,
filename: &str,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(sender_agent_id, "sender_agent_id")?;
validate_ascii_line(recipient, "recipient")?;
validate_ascii_line_opt(declared_mime, "declared_mime")?;
validate_ascii_line_opt(filename, "filename")?;
validate_nonce(nonce)?;
let msg = format!(
"aex-transfer-intent:{version}\nsender={sender}\nrecipient={recipient}\nsize={size}\nmime={mime}\nfilename={filename}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
sender = sender_agent_id,
recipient = recipient,
size = size_bytes,
mime = declared_mime,
filename = filename,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
pub fn data_ticket_bytes_v2(
transfer_id: &str,
recipient_agent_id: &str,
data_plane_url: &str,
expires_unix: i64,
nonce: &str,
) -> Result<Vec<u8>> {
validate_ascii_line(transfer_id, "transfer_id")?;
validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
validate_ascii_line(data_plane_url, "data_plane_url")?;
validate_nonce(nonce)?;
let msg = format!(
"aex-data-ticket:{version}\ntransfer={tx}\nrecipient={rec}\ndata_plane={dp}\nexpires={exp}\nnonce={nonce}",
version = PROTOCOL_VERSION_V2,
tx = transfer_id,
rec = recipient_agent_id,
dp = data_plane_url,
exp = expires_unix,
nonce = nonce,
);
Ok(msg.into_bytes())
}
pub fn rotate_key_challenge_bytes_v2(
agent_id: &str,
old_public_key_hex: &str,
new_public_key_hex: &str,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(agent_id, "agent_id")?;
validate_ascii_line(old_public_key_hex, "old_public_key_hex")?;
validate_ascii_line(new_public_key_hex, "new_public_key_hex")?;
validate_nonce(nonce)?;
if old_public_key_hex == new_public_key_hex {
return Err(Error::Internal(
"old_public_key_hex and new_public_key_hex must differ".into(),
));
}
let msg = format!(
"aex-rotate-key:{version}\nagent={agent}\nold_pub={old}\nnew_pub={new}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
agent = agent_id,
old = old_public_key_hex,
new = new_public_key_hex,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
pub fn transfer_receipt_bytes_v2(
recipient_agent_id: &str,
transfer_id: &str,
action: &str,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
validate_ascii_line(transfer_id, "transfer_id")?;
validate_ascii_line(action, "action")?;
validate_nonce(nonce)?;
if !matches!(action, "download" | "ack" | "inbox" | "request_ticket") {
return Err(Error::Internal(format!(
"action must be 'download', 'ack', 'inbox' or 'request_ticket', got {}",
action
)));
}
let msg = format!(
"aex-transfer-receipt:{version}\nrecipient={rec}\ntransfer={tx}\naction={act}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
rec = recipient_agent_id,
tx = transfer_id,
act = action,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
pub fn decision_request_bytes_v2(
recipient_agent_id: &str,
transfer_id: &str,
decision_id: &str,
eta_seconds: u64,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
validate_ascii_line(transfer_id, "transfer_id")?;
validate_ascii_line(decision_id, "decision_id")?;
validate_nonce(nonce)?;
let msg = format!(
"aex-decision-request:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\neta_secs={eta}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
rec = recipient_agent_id,
tx = transfer_id,
dec = decision_id,
eta = eta_seconds,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
pub fn decision_response_bytes_v2(
recipient_agent_id: &str,
transfer_id: &str,
decision_id: &str,
outcome: &str,
reason: &str,
nonce: &str,
issued_at_unix: i64,
) -> Result<Vec<u8>> {
validate_ascii_line(recipient_agent_id, "recipient_agent_id")?;
validate_ascii_line(transfer_id, "transfer_id")?;
validate_ascii_line(decision_id, "decision_id")?;
validate_ascii_line(outcome, "outcome")?;
validate_ascii_line_opt(reason, "reason")?;
validate_nonce(nonce)?;
if !matches!(outcome, "accepted" | "rejected") {
return Err(Error::Internal(format!(
"outcome must be 'accepted' or 'rejected', got {}",
outcome
)));
}
let msg = format!(
"aex-decision-response:{version}\nrecipient={rec}\ntransfer={tx}\ndecision={dec}\noutcome={out}\nreason={reason}\nnonce={nonce}\nts={ts}",
version = PROTOCOL_VERSION_V2,
rec = recipient_agent_id,
tx = transfer_id,
dec = decision_id,
out = outcome,
reason = reason,
nonce = nonce,
ts = issued_at_unix,
);
Ok(msg.into_bytes())
}
fn validate_ascii_line(s: &str, field: &str) -> Result<()> {
if s.is_empty() {
return Err(Error::Internal(format!("{} is empty", field)));
}
for (i, c) in s.chars().enumerate() {
if !c.is_ascii() || c == '\n' || c == '\r' || c == '\0' {
return Err(Error::Internal(format!(
"{} has invalid char at {}: {:?}",
field, i, c
)));
}
}
Ok(())
}
fn validate_ascii_line_opt(s: &str, field: &str) -> Result<()> {
if s.is_empty() {
return Ok(());
}
validate_ascii_line(s, field)
}
fn validate_nonce(nonce: &str) -> Result<()> {
if nonce.len() < MIN_NONCE_LEN || nonce.len() > MAX_NONCE_LEN {
return Err(Error::Internal(format!(
"nonce length {} outside [{}, {}]",
nonce.len(),
MIN_NONCE_LEN,
MAX_NONCE_LEN
)));
}
if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::Internal("nonce must be hex".into()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const NONCE: &str = "0123456789abcdef0123456789abcdef";
#[test]
fn v2_register_canonical_bytes_stable() {
let bytes =
registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
.unwrap();
let expected = "aex-register:v2\npub=aabbcc\norg=acme\nname=alice\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_transfer_intent_uses_did_uri() {
let bytes = transfer_intent_bytes_v2(
"did:web:acme.com#agent-vendite",
"did:web:beta-corp.com#acquisti",
12345,
"application/pdf",
"invoice.pdf",
NONCE,
1_700_000_000,
)
.unwrap();
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";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_transfer_intent_with_legacy_spize_id() {
let bytes = transfer_intent_bytes_v2(
"spize:acme/alice:aabbcc",
"did:ethr:0x14a34:0xabc",
100,
"",
"",
NONCE,
1_700_000_000,
)
.unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.starts_with("aex-transfer-intent:v2\n"));
assert!(s.contains("sender=spize:acme/alice:aabbcc\n"));
assert!(s.contains("recipient=did:ethr:0x14a34:0xabc\n"));
}
#[test]
fn v2_data_ticket_stable() {
let bytes = data_ticket_bytes_v2(
"tx_abc123",
"did:web:acme.com#bob",
"https://data.acme.com",
1_700_000_100,
NONCE,
)
.unwrap();
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";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_rotate_key_stable() {
let old = "1".repeat(64);
let new = "2".repeat(64);
let bytes = rotate_key_challenge_bytes_v2(
"did:spize:acme/alice#aabbcc",
&old,
&new,
NONCE,
1_700_000_000,
)
.unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.starts_with("aex-rotate-key:v2\n"));
assert!(s.contains("agent=did:spize:acme/alice#aabbcc\n"));
}
#[test]
fn v2_receipt_stable() {
let bytes = transfer_receipt_bytes_v2(
"did:web:beta-corp.com#acquisti",
"tx_abc123",
"ack",
NONCE,
1_700_000_000,
)
.unwrap();
let expected = "aex-transfer-receipt:v2\nrecipient=did:web:beta-corp.com#acquisti\ntransfer=tx_abc123\naction=ack\nnonce=0123456789abcdef0123456789abcdef\nts=1700000000";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_clock_skew_60s_window() {
let now = 1_700_000_000_i64;
assert!(is_within_clock_skew_v2(now, now));
assert!(is_within_clock_skew_v2(now, now - 60));
assert!(is_within_clock_skew_v2(now, now + 60));
assert!(!is_within_clock_skew_v2(now, now - 61));
assert!(!is_within_clock_skew_v2(now, now + 61));
}
#[test]
fn v2_clock_skew_extreme_inputs_do_not_panic() {
let now = 1_700_000_000_i64;
assert!(!is_within_clock_skew_v2(now, i64::MIN));
assert!(!is_within_clock_skew_v2(now, i64::MAX));
assert!(!is_within_clock_skew_v2(i64::MAX, i64::MIN));
}
#[test]
fn v2_newline_in_field_rejected() {
let err = registration_challenge_bytes_v2("aa", "ac\nme", "alice", NONCE, 100).unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_non_ascii_field_rejected() {
let err = registration_challenge_bytes_v2("aa", "acmè", "alice", NONCE, 100).unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_short_nonce_rejected() {
let err =
registration_challenge_bytes_v2("aa", "acme", "alice", "deadbeef", 100).unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_non_hex_nonce_rejected() {
let err = registration_challenge_bytes_v2("aa", "acme", "alice", &"z".repeat(32), 100)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_rotate_key_rejects_same_old_and_new() {
let same = "a".repeat(64);
let err = rotate_key_challenge_bytes_v2(
"did:spize:acme/alice#aabbcc",
&same,
&same,
NONCE,
1_700_000_000,
)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_receipt_rejects_bad_action() {
let err =
transfer_receipt_bytes_v2("did:web:beta-corp.com#bob", "tx_abc", "overwrite", NONCE, 1)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_data_ticket_rejects_newline_url() {
let err = data_ticket_bytes_v2(
"tx_abc",
"did:web:acme.com#bob",
"https://evil.test\nspoof",
1,
NONCE,
)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_decision_request_stable() {
let bytes = decision_request_bytes_v2(
"did:web:acme.com#agent-vendite",
"tx_abc123",
"dec_0001",
86_400,
NONCE,
1_700_000_000,
)
.unwrap();
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";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_decision_response_accepted_stable() {
let bytes = decision_response_bytes_v2(
"did:web:acme.com#agent-vendite",
"tx_abc123",
"dec_0001",
"accepted",
"",
NONCE,
1_700_000_000,
)
.unwrap();
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";
assert_eq!(bytes, expected.as_bytes());
}
#[test]
fn v2_decision_response_rejected_with_reason_stable() {
let bytes = decision_response_bytes_v2(
"did:web:acme.com#agent-vendite",
"tx_abc123",
"dec_0001",
"rejected",
"operator declined: budget exceeded",
NONCE,
1_700_000_000,
)
.unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.starts_with("aex-decision-response:v2\n"));
assert!(s.contains("outcome=rejected\n"));
assert!(s.contains("reason=operator declined: budget exceeded\n"));
}
#[test]
fn v2_decision_response_rejects_bad_outcome() {
let err = decision_response_bytes_v2(
"did:web:acme.com#agent-vendite",
"tx_abc123",
"dec_0001",
"maybe",
"",
NONCE,
1_700_000_000,
)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_decision_request_rejects_newline_in_fields() {
let err = decision_request_bytes_v2(
"did:web:acme.com#agent-vendite",
"tx_abc\n123",
"dec_0001",
60,
NONCE,
1_700_000_000,
)
.unwrap_err();
assert!(matches!(err, Error::Internal(_)));
}
#[test]
fn v2_decision_messages_distinguish_request_from_response() {
let req = decision_request_bytes_v2(
"did:web:acme.com#x",
"tx_1",
"dec_1",
60,
NONCE,
1_700_000_000,
)
.unwrap();
let resp = decision_response_bytes_v2(
"did:web:acme.com#x",
"tx_1",
"dec_1",
"accepted",
"",
NONCE,
1_700_000_000,
)
.unwrap();
assert_ne!(req, resp);
assert!(std::str::from_utf8(&req)
.unwrap()
.starts_with("aex-decision-request:v2\n"));
assert!(std::str::from_utf8(&resp)
.unwrap()
.starts_with("aex-decision-response:v2\n"));
}
#[test]
fn v2_prefix_differs_from_v1_for_identical_inputs() {
let v1 = crate::wire::registration_challenge_bytes(
"aabbcc",
"acme",
"alice",
NONCE,
1_700_000_000,
)
.unwrap();
let v2 = registration_challenge_bytes_v2("aabbcc", "acme", "alice", NONCE, 1_700_000_000)
.unwrap();
assert_ne!(v1, v2);
assert!(std::str::from_utf8(&v1).unwrap().starts_with("spize-"));
assert!(std::str::from_utf8(&v2).unwrap().starts_with("aex-"));
}
}