use crate::jcs;
use crate::{Aid, Timestamp};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AitpEnvelope {
pub version: String,
pub message_type: MessageType,
pub message_id: Uuid,
pub timestamp: Timestamp,
pub sender: Sender,
pub payload: serde_json::Value,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Sender {
pub agent_id: Aid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MessageType {
MutualHello,
MutualHelloAck,
MutualCommit,
MutualCommitAck,
Tct,
PopChallenge,
PopResponse,
Error,
}
impl MessageType {
pub fn as_wire_str(&self) -> &'static str {
match self {
Self::MutualHello => "mutual_hello",
Self::MutualHelloAck => "mutual_hello_ack",
Self::MutualCommit => "mutual_commit",
Self::MutualCommitAck => "mutual_commit_ack",
Self::Tct => "tct",
Self::PopChallenge => "pop_challenge",
Self::PopResponse => "pop_response",
Self::Error => "error",
}
}
}
pub fn envelope_signing_input(
message_id: &Uuid,
timestamp: Timestamp,
sender_aid: &Aid,
payload: &serde_json::Value,
) -> Result<Vec<u8>, jcs::JcsError> {
use sha2::{Digest, Sha256};
let canonical = jcs::canonicalize(payload)?;
let payload_hash = Sha256::digest(&canonical);
let mut hex_buf = [0u8; 64];
hex::encode_to_slice(payload_hash, &mut hex_buf)
.expect("64-byte buffer fits 32-byte digest hex-encoded");
let payload_hex = std::str::from_utf8(&hex_buf).expect("hex output is always ASCII");
Ok(format!(
"{}|{}|{}|{}",
message_id,
timestamp.0,
sender_aid.as_str(),
payload_hex
)
.into_bytes())
}
pub fn envelope_signing_digest(
message_id: &Uuid,
timestamp: Timestamp,
sender_aid: &Aid,
payload: &serde_json::Value,
) -> Result<[u8; 32], jcs::JcsError> {
use sha2::{Digest, Sha256};
let input = envelope_signing_input(message_id, timestamp, sender_aid, payload)?;
Ok(Sha256::digest(&input).into())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_aid() -> Aid {
Aid::from_ed25519(&[0u8; 32])
}
fn sample_envelope(mt: MessageType) -> AitpEnvelope {
AitpEnvelope {
version: "aitp/0.2".into(),
message_type: mt,
message_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
timestamp: Timestamp(1_711_900_000),
sender: Sender {
agent_id: sample_aid(),
},
payload: json!({"x": 1}),
signature: "A".repeat(86),
}
}
#[test]
fn round_trip_each_message_type() {
for mt in [
MessageType::MutualHello,
MessageType::MutualHelloAck,
MessageType::MutualCommit,
MessageType::MutualCommitAck,
MessageType::Tct,
MessageType::PopChallenge,
MessageType::PopResponse,
MessageType::Error,
] {
let env = sample_envelope(mt);
let s = serde_json::to_string(&env).unwrap();
let parsed: AitpEnvelope = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, env, "round-trip for {:?}", mt);
}
}
#[test]
fn rejects_unknown_top_level_field() {
let mut v = serde_json::to_value(sample_envelope(MessageType::MutualHello)).unwrap();
v.as_object_mut().unwrap().insert("rogue".into(), json!(1));
let s = serde_json::to_string(&v).unwrap();
let err = serde_json::from_str::<AitpEnvelope>(&s).unwrap_err();
assert!(err.to_string().contains("rogue"), "got: {}", err);
}
#[test]
fn rejects_unknown_sender_field() {
let bad = json!({
"version": "aitp/0.2",
"message_type": "tct",
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1711900000,
"sender": {"agent_id": sample_aid().as_str(), "rogue": 1},
"payload": {},
"signature": "A".repeat(86),
});
let err = serde_json::from_value::<AitpEnvelope>(bad).unwrap_err();
assert!(err.to_string().contains("rogue"), "got: {}", err);
}
#[test]
fn rejects_extensions_field() {
let mut v = serde_json::to_value(sample_envelope(MessageType::Tct)).unwrap();
v.as_object_mut()
.unwrap()
.insert("extensions".into(), json!({}));
let err = serde_json::from_str::<AitpEnvelope>(&v.to_string()).unwrap_err();
assert!(err.to_string().contains("extensions"), "got: {}", err);
}
#[test]
fn signing_input_is_pipe_formatted() {
let mid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let aid = sample_aid();
let input =
envelope_signing_input(&mid, Timestamp(1_700_000_000), &aid, &json!({})).unwrap();
let s = String::from_utf8(input).unwrap();
assert_eq!(s.matches('|').count(), 3);
assert!(s.starts_with("550e8400-e29b-41d4-a716-446655440000|1700000000|"));
let parts: Vec<&str> = s.split('|').collect();
assert_eq!(parts[3].len(), 64);
assert!(parts[3].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn signing_digest_is_deterministic() {
let mid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let aid = sample_aid();
let payload = json!({"foo": "bar", "n": 1});
let d1 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload).unwrap();
let d2 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload).unwrap();
assert_eq!(d1, d2);
let payload2 = json!({"n": 1, "foo": "bar"});
let d3 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload2).unwrap();
assert_eq!(d1, d3);
}
#[test]
fn message_type_wire_strings() {
let cases = [
(MessageType::MutualHello, "mutual_hello"),
(MessageType::MutualHelloAck, "mutual_hello_ack"),
(MessageType::MutualCommit, "mutual_commit"),
(MessageType::MutualCommitAck, "mutual_commit_ack"),
(MessageType::Tct, "tct"),
(MessageType::PopChallenge, "pop_challenge"),
(MessageType::PopResponse, "pop_response"),
(MessageType::Error, "error"),
];
for (mt, wire) in cases {
assert_eq!(mt.as_wire_str(), wire);
let v = serde_json::to_value(mt).unwrap();
assert_eq!(v.as_str().unwrap(), wire);
}
}
}