Skip to main content

xmtp_proto/types/
backend_envelope.rs

1use prost::Message;
2use xmtp_common::sha256_array;
3
4use crate::xmtp::backend::v1::ClientEnvelope;
5
6/// Canonical outer encoding and its hash. Inner payload bytes are unchanged.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct CanonicalEnvelope {
9    /// Prost's canonical field ordering for the outer envelope.
10    pub bytes: Vec<u8>,
11    /// SHA-256 of `bytes`, used as the publish idempotency key.
12    pub hash: [u8; 32],
13}
14
15/// Encode the outer envelope once for storage, retries, and hash matching.
16///
17/// Inner payload bytes are copied exactly as supplied. This function does not
18/// validate the payload or calculate the separate client MLS message ID.
19// implements: API-211
20pub fn canonical_envelope(envelope: &ClientEnvelope) -> CanonicalEnvelope {
21    let bytes = envelope.encode_to_vec();
22    let hash = sha256_array(&bytes);
23    CanonicalEnvelope { bytes, hash }
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use crate::xmtp::{
30        backend::v1::{
31            GroupMessage, KeyPackage, SealedCommitLogEntry, WelcomeMessage,
32            client_envelope::Payload,
33            welcome_message::{V1, Version},
34        },
35        identity::associations::IdentityUpdate,
36    };
37
38    /// Same vector on native and wasm.
39    // verifies: API-211, API-210
40    #[xmtp_common::test(unwrap_try = true)]
41    fn all_payloads_have_stable_canonical_bytes_and_distinct_outer_hashes() {
42        // Reordered inner fields and an unknown outer field are noncanonical.
43        let input = hex::decode("0a0b1801120201020a0300ff42800100")?;
44        let group_envelope = ClientEnvelope::decode(input.as_slice())?;
45        assert_eq!(
46            group_envelope.payload,
47            Some(Payload::GroupMessage(GroupMessage {
48                data: vec![0, 255, 66],
49                sender_hmac: vec![1, 2],
50                should_push: true,
51                signature: vec![],
52            }))
53        );
54
55        let cases = [
56            (
57                group_envelope.clone(),
58                "0a0b0a0300ff42120201021801",
59                "5fd2916dcdd5fe79bb09ab21d545d5637307ace6e5352d4c2d287dbdd9338888",
60            ),
61            (
62                ClientEnvelope {
63                    payload: Some(Payload::WelcomeMessage(WelcomeMessage {
64                        version: Some(Version::V1(V1 {
65                            installation_key: vec![0x10, 0x11],
66                            data: vec![0x12],
67                            hpke_public_key: vec![0x13, 0x14],
68                            wrapper_algorithm: 1,
69                            welcome_metadata: vec![0x15],
70                        })),
71                    })),
72                },
73                "12120a100a0210111201121a02131420012a0115",
74                "9a47fe38765eeebb1f272663381503c987fabbf9bb99b023fc88eaaffda60d5f",
75            ),
76            (
77                ClientEnvelope {
78                    payload: Some(Payload::KeyPackage(KeyPackage {
79                        key_package_tls_serialized: vec![0x20, 0x21, 0x22],
80                    })),
81                },
82                "1a050a03202122",
83                "3f46232f37d76bcc2e80d3c54c9f9d5a7273d78ef8f91336249ef79746fd06dc",
84            ),
85            (
86                ClientEnvelope {
87                    payload: Some(Payload::IdentityUpdate(IdentityUpdate {
88                        client_timestamp_ns: 7,
89                        inbox_id: "ab".into(),
90                        ..Default::default()
91                    })),
92                },
93                "220610071a026162",
94                "0aab40455ce3dacc36006ef421e2746016960606e24d125946e7a73dc9cf6f9b",
95            ),
96            // The signature is field 4 of the group message.
97            (
98                ClientEnvelope {
99                    payload: Some(Payload::GroupMessage(GroupMessage {
100                        data: vec![0, 255, 66],
101                        sender_hmac: vec![1, 2],
102                        should_push: true,
103                        signature: vec![0x41, 0x42],
104                    })),
105                },
106                "0a0f0a0300ff4212020102180122024142",
107                "4b88066b5fe9b4de9d603021b078abe4b19255dddfce9d074237a717a2c7854d",
108            ),
109            // A sealed entry is field 6, with fields 1 to 4 in order.
110            (
111                ClientEnvelope {
112                    payload: Some(Payload::SealedCommitLogEntry(SealedCommitLogEntry {
113                        commit_log_id: vec![0x50, 0x51],
114                        nonce: vec![0x52],
115                        ciphertext: vec![0x53, 0x54],
116                        signature: vec![0x55],
117                    })),
118                },
119                "320e0a0250511201521a025354220155",
120                "a3d80629f11646414c6170dfe64f675b4d2328a23082169379fdd1b28b5581bf",
121            ),
122        ];
123
124        for (envelope, expected_bytes, expected_hash) in cases {
125            let canonical = canonical_envelope(&envelope);
126            assert_eq!(hex::encode(&canonical.bytes), expected_bytes);
127            assert_eq!(hex::encode(canonical.hash), expected_hash);
128            let decoded = ClientEnvelope::decode(canonical.bytes.as_slice())?;
129            assert_eq!(decoded, envelope);
130            assert_eq!(canonical_envelope(&decoded), canonical);
131        }
132
133        // Field 5, the retired `CommitLogEntry`, is reserved. An old client's
134        // entry decodes with no payload, and its bytes are not kept.
135        let retired =
136            ClientEnvelope::decode(hex::decode("2a0d0a02303112070a013212023334")?.as_slice())?;
137        assert_eq!(retired.payload, None);
138        assert!(canonical_envelope(&retired).bytes.is_empty());
139
140        let Some(Payload::GroupMessage(group)) = group_envelope.payload else {
141            unreachable!("first golden vector is a group message")
142        };
143        assert_eq!(
144            hex::encode(sha256_array(&group.data)),
145            "f803bec586282caafe409609aae90eb09f6d4cddb6e04431ddf76d22e7dcacd6"
146        );
147        assert_ne!(
148            canonical_envelope(&ClientEnvelope {
149                payload: Some(Payload::GroupMessage(group.clone())),
150            })
151            .hash,
152            sha256_array(&group.data)
153        );
154    }
155}