1use crate::jcs;
24use crate::{Aid, Timestamp};
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(deny_unknown_fields)]
40pub struct AitpEnvelope {
41 pub version: String,
43
44 pub message_type: MessageType,
46
47 pub message_id: Uuid,
50
51 pub timestamp: Timestamp,
53
54 pub sender: Sender,
56
57 pub payload: serde_json::Value,
60
61 pub signature: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(deny_unknown_fields)]
69pub struct Sender {
70 pub agent_id: Aid,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum MessageType {
83 MutualHello,
85 MutualHelloAck,
87 MutualCommit,
89 MutualCommitAck,
91 Tct,
93 PopChallenge,
95 PopResponse,
97 Error,
99}
100
101impl MessageType {
102 pub fn as_wire_str(&self) -> &'static str {
104 match self {
105 Self::MutualHello => "mutual_hello",
106 Self::MutualHelloAck => "mutual_hello_ack",
107 Self::MutualCommit => "mutual_commit",
108 Self::MutualCommitAck => "mutual_commit_ack",
109 Self::Tct => "tct",
110 Self::PopChallenge => "pop_challenge",
111 Self::PopResponse => "pop_response",
112 Self::Error => "error",
113 }
114 }
115}
116
117pub fn envelope_signing_input(
125 message_id: &Uuid,
126 timestamp: Timestamp,
127 sender_aid: &Aid,
128 payload: &serde_json::Value,
129) -> Result<Vec<u8>, jcs::JcsError> {
130 use sha2::{Digest, Sha256};
131 let canonical = jcs::canonicalize(payload)?;
132 let payload_hash = Sha256::digest(&canonical);
133 let mut hex_buf = [0u8; 64];
134 hex::encode_to_slice(payload_hash, &mut hex_buf)
135 .expect("64-byte buffer fits 32-byte digest hex-encoded");
136 let payload_hex = std::str::from_utf8(&hex_buf).expect("hex output is always ASCII");
137 Ok(format!(
138 "{}|{}|{}|{}",
139 message_id,
140 timestamp.0,
141 sender_aid.as_str(),
142 payload_hex
143 )
144 .into_bytes())
145}
146
147pub fn envelope_signing_digest(
152 message_id: &Uuid,
153 timestamp: Timestamp,
154 sender_aid: &Aid,
155 payload: &serde_json::Value,
156) -> Result<[u8; 32], jcs::JcsError> {
157 use sha2::{Digest, Sha256};
158 let input = envelope_signing_input(message_id, timestamp, sender_aid, payload)?;
159 Ok(Sha256::digest(&input).into())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use serde_json::json;
166
167 fn sample_aid() -> Aid {
168 Aid::from_ed25519(&[0u8; 32])
169 }
170
171 fn sample_envelope(mt: MessageType) -> AitpEnvelope {
172 AitpEnvelope {
173 version: "aitp/0.2".into(),
174 message_type: mt,
175 message_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
176 timestamp: Timestamp(1_711_900_000),
177 sender: Sender {
178 agent_id: sample_aid(),
179 },
180 payload: json!({"x": 1}),
181 signature: "A".repeat(86),
182 }
183 }
184
185 #[test]
186 fn round_trip_each_message_type() {
187 for mt in [
188 MessageType::MutualHello,
189 MessageType::MutualHelloAck,
190 MessageType::MutualCommit,
191 MessageType::MutualCommitAck,
192 MessageType::Tct,
193 MessageType::PopChallenge,
194 MessageType::PopResponse,
195 MessageType::Error,
196 ] {
197 let env = sample_envelope(mt);
198 let s = serde_json::to_string(&env).unwrap();
199 let parsed: AitpEnvelope = serde_json::from_str(&s).unwrap();
200 assert_eq!(parsed, env, "round-trip for {:?}", mt);
201 }
202 }
203
204 #[test]
205 fn rejects_unknown_top_level_field() {
206 let mut v = serde_json::to_value(sample_envelope(MessageType::MutualHello)).unwrap();
207 v.as_object_mut().unwrap().insert("rogue".into(), json!(1));
208 let s = serde_json::to_string(&v).unwrap();
209 let err = serde_json::from_str::<AitpEnvelope>(&s).unwrap_err();
210 assert!(err.to_string().contains("rogue"), "got: {}", err);
211 }
212
213 #[test]
214 fn rejects_unknown_sender_field() {
215 let bad = json!({
216 "version": "aitp/0.2",
217 "message_type": "tct",
218 "message_id": "550e8400-e29b-41d4-a716-446655440000",
219 "timestamp": 1711900000,
220 "sender": {"agent_id": sample_aid().as_str(), "rogue": 1},
221 "payload": {},
222 "signature": "A".repeat(86),
223 });
224 let err = serde_json::from_value::<AitpEnvelope>(bad).unwrap_err();
225 assert!(err.to_string().contains("rogue"), "got: {}", err);
226 }
227
228 #[test]
229 fn rejects_extensions_field() {
230 let mut v = serde_json::to_value(sample_envelope(MessageType::Tct)).unwrap();
232 v.as_object_mut()
233 .unwrap()
234 .insert("extensions".into(), json!({}));
235 let err = serde_json::from_str::<AitpEnvelope>(&v.to_string()).unwrap_err();
236 assert!(err.to_string().contains("extensions"), "got: {}", err);
237 }
238
239 #[test]
240 fn signing_input_is_pipe_formatted() {
241 let mid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
242 let aid = sample_aid();
243 let input =
244 envelope_signing_input(&mid, Timestamp(1_700_000_000), &aid, &json!({})).unwrap();
245 let s = String::from_utf8(input).unwrap();
246 assert_eq!(s.matches('|').count(), 3);
248 assert!(s.starts_with("550e8400-e29b-41d4-a716-446655440000|1700000000|"));
249 let parts: Vec<&str> = s.split('|').collect();
251 assert_eq!(parts[3].len(), 64);
252 assert!(parts[3].chars().all(|c| c.is_ascii_hexdigit()));
253 }
254
255 #[test]
256 fn signing_digest_is_deterministic() {
257 let mid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
258 let aid = sample_aid();
259 let payload = json!({"foo": "bar", "n": 1});
260 let d1 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload).unwrap();
261 let d2 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload).unwrap();
262 assert_eq!(d1, d2);
263 let payload2 = json!({"n": 1, "foo": "bar"});
265 let d3 = envelope_signing_digest(&mid, Timestamp(1), &aid, &payload2).unwrap();
266 assert_eq!(d1, d3);
267 }
268
269 #[test]
270 fn message_type_wire_strings() {
271 let cases = [
272 (MessageType::MutualHello, "mutual_hello"),
273 (MessageType::MutualHelloAck, "mutual_hello_ack"),
274 (MessageType::MutualCommit, "mutual_commit"),
275 (MessageType::MutualCommitAck, "mutual_commit_ack"),
276 (MessageType::Tct, "tct"),
277 (MessageType::PopChallenge, "pop_challenge"),
278 (MessageType::PopResponse, "pop_response"),
279 (MessageType::Error, "error"),
280 ];
281 for (mt, wire) in cases {
282 assert_eq!(mt.as_wire_str(), wire);
283 let v = serde_json::to_value(mt).unwrap();
285 assert_eq!(v.as_str().unwrap(), wire);
286 }
287 }
288}