Skip to main content

aitp_core/
envelope.rs

1//! AITP message envelope (RFC-AITP-0001 §5).
2//!
3//! Every AITP protocol message — handshake, TCT delivery, PoP exchange,
4//! errors — is wrapped in an [`AitpEnvelope`]. The envelope provides
5//! sender identity, replay protection (`message_id`, `timestamp`), and
6//! end-to-end Ed25519 signing.
7//!
8//! ## Signing input (RFC-AITP-0001 §5.4)
9//!
10//! The envelope signature is **not** computed by JCS-canonicalizing the whole
11//! envelope. Instead:
12//!
13//! ```text
14//! payload_hash = sha256(JCS(payload))
15//! sig_input    = message_id + "|" + timestamp_string + "|" + sender.agent_id + "|" + hex(payload_hash)
16//! signature    = base64url(sign(private_key, sha256(sig_input)))
17//! ```
18//!
19//! [`envelope_signing_input`] computes `sig_input` for a partially-built
20//! envelope; [`envelope_signing_digest`] returns the SHA-256 of that input —
21//! the actual 32 bytes that get fed into Ed25519.
22
23use crate::jcs;
24use crate::{Aid, Timestamp};
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28/// The standard AITP message envelope (RFC-AITP-0001 §5.1).
29///
30/// `payload` is kept as raw JSON so that protocol-specific crates
31/// (`aitp-handshake`, `aitp-tct`, etc.) can parse it into their own typed
32/// payload structs. The envelope crate does not need to know every payload
33/// type.
34///
35/// The schema is `additionalProperties: false` — the envelope has no
36/// `extensions` slot. Forward compatibility happens inside `payload` per
37/// RFC-AITP-0012.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(deny_unknown_fields)]
40pub struct AitpEnvelope {
41    /// Protocol version. MUST be `"aitp/0.2"`.
42    pub version: String,
43
44    /// Wire-level message type.
45    pub message_type: MessageType,
46
47    /// UUID v4 (hyphenated lowercase). Used for replay-prevention
48    /// deduplication.
49    pub message_id: Uuid,
50
51    /// Unix timestamp in seconds.
52    pub timestamp: Timestamp,
53
54    /// Identifier of the sending agent.
55    pub sender: Sender,
56
57    /// Type-specific payload, kept as raw JSON until parsed by a protocol
58    /// crate.
59    pub payload: serde_json::Value,
60
61    /// base64url-unpadded Ed25519 signature over
62    /// `sha256(message_id|ts|sender|hex(sha256(jcs(payload))))`.
63    pub signature: String,
64}
65
66/// Sender identification block.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(deny_unknown_fields)]
69pub struct Sender {
70    /// AID of the sending agent.
71    pub agent_id: Aid,
72}
73
74/// Wire-level message type discriminant.
75///
76/// Marked `#[non_exhaustive]` so future protocol extensions (new
77/// envelope message types added in RFC-AITP minor revisions) do not
78/// break downstream `match` arms.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum MessageType {
83    /// Initiating peer's handshake opener (RFC-AITP-0004).
84    MutualHello,
85    /// Responding peer's reply to MutualHello.
86    MutualHelloAck,
87    /// Initiating peer's TCT + PoP delivery.
88    MutualCommit,
89    /// Responding peer's TCT + PoP delivery (handshake complete).
90    MutualCommitAck,
91    /// A standalone TCT delivery (for renewal flows).
92    Tct,
93    /// Downstream PoP challenge (RFC-AITP-0005 §6).
94    PopChallenge,
95    /// Downstream PoP response.
96    PopResponse,
97    /// Error envelope.
98    Error,
99}
100
101impl MessageType {
102    /// The wire string for this message type (snake_case).
103    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
117/// Compute the envelope signing input per RFC-AITP-0001 §5.4.
118///
119/// Returns the bytes that will be SHA-256'd before signing. Produced as:
120///
121/// ```text
122/// message_id + "|" + timestamp + "|" + sender.agent_id + "|" + hex(sha256(JCS(payload)))
123/// ```
124pub 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
147/// Compute the 32-byte SHA-256 digest of [`envelope_signing_input`].
148///
149/// This is the value the issuer Ed25519-signs, and the value verifiers
150/// re-compute from a received envelope before checking the signature.
151pub 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        // Schema is additionalProperties:false — no top-level `extensions`.
231        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        // Three pipes between four components.
247        assert_eq!(s.matches('|').count(), 3);
248        assert!(s.starts_with("550e8400-e29b-41d4-a716-446655440000|1700000000|"));
249        // Last component is hex of sha256("{}") which is sha256 of canonical empty obj.
250        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        // Reordering JSON keys should not change the digest (JCS).
264        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            // Also verify serde produces the same wire string.
284            let v = serde_json::to_value(mt).unwrap();
285            assert_eq!(v.as_str().unwrap(), wire);
286        }
287    }
288}