Skip to main content

xmtp_mls_common/invite/
payload.rs

1//! Helpers for the [`ExternalInvitePayload`] proto.
2//!
3//! Centralises the small but easy-to-get-wrong pieces of building and
4//! validating an external-invite payload:
5//!
6//! * fresh symmetric keys / nonces / external-group-ids from the workspace CSPRNG
7//! * recognising / unwrapping the `oneof version { V1 v1 }` envelope
8//! * a [`build_payload`] convenience constructor
9//!
10//! The actual encryption of the [`GroupInfo`] blob is performed by the
11//! sibling `encrypted_group_info` module (which also owns the blob-side
12//! expiry semantics, since `expires_at_ns` lives on the
13//! [`EncryptedGroupInfoBlob`] envelope and not the payload).
14//!
15//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload
16//! [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob
17//! [`GroupInfo`]: openmls::messages::group_info::GroupInfo
18
19use thiserror::Error;
20use xmtp_proto::xmtp::mls::message_contents::{
21    ExternalInvitePayload, ExternalInvitePayloadV1,
22    external_invite_payload::Version as ExternalInvitePayloadVersion,
23};
24
25/// Length in bytes of the ChaCha20Poly1305 key used to wrap the encrypted
26/// `GroupInfo` blob referenced by an [`ExternalInvitePayload`].
27pub const SYMMETRIC_KEY_LEN: usize = 32;
28
29/// Length in bytes of the ChaCha20Poly1305 nonce used alongside
30/// [`SYMMETRIC_KEY_LEN`]-byte keys.
31pub const NONCE_LEN: usize = 12;
32
33/// Minimum length of `external_group_id`. The proto schema enforces this as
34/// MUST; tiny services that don't need much collision resistance may pick
35/// the floor, but `RECOMMENDED_EXTERNAL_GROUP_ID_LEN` random bytes is the
36/// libxmtp default when no application-specific scheme is in use.
37pub const MIN_EXTERNAL_GROUP_ID_LEN: usize = 4;
38
39/// Recommended random length for `external_group_id` when callers don't
40/// have an application-specific scheme. 16 bytes (128 bits) gives ample
41/// collision resistance for any realistic single-service deployment.
42pub const RECOMMENDED_EXTERNAL_GROUP_ID_LEN: usize = 16;
43
44/// Errors returned when validating an [`ExternalInvitePayload`].
45#[derive(Debug, Error, PartialEq, Eq)]
46pub enum InvitePayloadError {
47    /// The payload's `version` oneof carries a variant this build does not
48    /// recognize, or is unset entirely.
49    #[error("unsupported or missing external-invite payload version")]
50    UnsupportedVersion,
51    /// `external_group_id` was shorter than [`MIN_EXTERNAL_GROUP_ID_LEN`].
52    #[error("external_group_id must be at least {min} bytes (got {len})", min = MIN_EXTERNAL_GROUP_ID_LEN)]
53    InvalidExternalGroupIdLength {
54        /// Observed length.
55        len: usize,
56    },
57    /// `symmetric_key` was not exactly [`SYMMETRIC_KEY_LEN`] bytes.
58    #[error("symmetric_key must be exactly {SYMMETRIC_KEY_LEN} bytes (got {0})")]
59    InvalidSymmetricKeyLength(usize),
60}
61
62/// Generate a fresh 32-byte symmetric key from the workspace CSPRNG.
63///
64/// The key is intended for use with ChaCha20Poly1305 when wrapping the
65/// encrypted GroupInfo blob referenced by the resulting
66/// [`ExternalInvitePayload`].
67pub fn generate_symmetric_key() -> [u8; SYMMETRIC_KEY_LEN] {
68    xmtp_common::rand_array::<SYMMETRIC_KEY_LEN>()
69}
70
71/// Generate a fresh 12-byte nonce from the workspace CSPRNG.
72///
73/// Intended for use with ChaCha20Poly1305 alongside a key produced by
74/// [`generate_symmetric_key`]. The nonce is *not* stored in the payload
75/// itself — it lives next to the ciphertext in the encrypted GroupInfo blob.
76pub fn generate_nonce() -> [u8; NONCE_LEN] {
77    xmtp_common::rand_array::<NONCE_LEN>()
78}
79
80/// Generate a fresh random `external_group_id` of the recommended length
81/// ([`RECOMMENDED_EXTERNAL_GROUP_ID_LEN`] bytes from the workspace CSPRNG).
82///
83/// Callers with application-specific identifier schemes (UUIDs, snowflakes,
84/// short slot keys, …) should construct their own bytes instead — this
85/// helper exists as the safe default.
86pub fn generate_external_group_id() -> [u8; RECOMMENDED_EXTERNAL_GROUP_ID_LEN] {
87    xmtp_common::rand_array::<RECOMMENDED_EXTERNAL_GROUP_ID_LEN>()
88}
89
90/// Validate that `payload.version` carries a recognised variant and that
91/// the V1 fields meet their length requirements.
92///
93/// Currently the only recognised variant is V1. Future versions extend the
94/// oneof; unknown variants are rejected (fail closed).
95pub fn validate(
96    payload: &ExternalInvitePayload,
97) -> Result<&ExternalInvitePayloadV1, InvitePayloadError> {
98    let v1 = match &payload.version {
99        Some(ExternalInvitePayloadVersion::V1(v1)) => v1,
100        None => return Err(InvitePayloadError::UnsupportedVersion),
101    };
102    if v1.external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN {
103        return Err(InvitePayloadError::InvalidExternalGroupIdLength {
104            len: v1.external_group_id.len(),
105        });
106    }
107    if v1.symmetric_key.len() != SYMMETRIC_KEY_LEN {
108        return Err(InvitePayloadError::InvalidSymmetricKeyLength(
109            v1.symmetric_key.len(),
110        ));
111    }
112    Ok(v1)
113}
114
115/// Build an [`ExternalInvitePayload`] wrapping a [`ExternalInvitePayloadV1`]
116/// with the supplied fields.
117///
118/// * `service_pointer` — application-defined opaque bytes describing where
119///   the encrypted GroupInfo blob can be fetched.
120/// * `external_group_id` — service-slot identifier carried on the wire and
121///   verified by the joiner against the group's
122///   `EXTERNAL_COMMIT_POLICY.external_group_id` after joining. MUST be at
123///   least [`MIN_EXTERNAL_GROUP_ID_LEN`] bytes; checked at construction so
124///   callers surface the error at the build site rather than at
125///   [`validate`] time.
126/// * `symmetric_key` — typically the output of [`generate_symmetric_key`].
127///   Length is type-enforced.
128pub fn build_payload(
129    service_pointer: Vec<u8>,
130    external_group_id: Vec<u8>,
131    symmetric_key: [u8; SYMMETRIC_KEY_LEN],
132) -> Result<ExternalInvitePayload, InvitePayloadError> {
133    if external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN {
134        return Err(InvitePayloadError::InvalidExternalGroupIdLength {
135            len: external_group_id.len(),
136        });
137    }
138    Ok(ExternalInvitePayload {
139        version: Some(ExternalInvitePayloadVersion::V1(ExternalInvitePayloadV1 {
140            service_pointer,
141            external_group_id,
142            symmetric_key: symmetric_key.to_vec(),
143        })),
144    })
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn well_formed_payload() -> ExternalInvitePayload {
152        build_payload(
153            b"https://invites.example/abc".to_vec(),
154            generate_external_group_id().to_vec(),
155            [0x42u8; SYMMETRIC_KEY_LEN],
156        )
157        .expect("recommended-length external_group_id is well-formed")
158    }
159
160    #[xmtp_common::test(unwrap_try = true)]
161    fn key_nonce_and_id_are_random() {
162        let k1 = generate_symmetric_key();
163        let k2 = generate_symmetric_key();
164        assert_eq!(k1.len(), SYMMETRIC_KEY_LEN);
165        assert_eq!(k2.len(), SYMMETRIC_KEY_LEN);
166        assert_ne!(k1, k2, "two CSPRNG-generated keys must differ");
167        assert_ne!(k1, [0u8; SYMMETRIC_KEY_LEN], "key must not be all-zero");
168
169        let n1 = generate_nonce();
170        let n2 = generate_nonce();
171        assert_eq!(n1.len(), NONCE_LEN);
172        assert_eq!(n2.len(), NONCE_LEN);
173        assert_ne!(n1, n2, "two CSPRNG-generated nonces must differ");
174
175        let id1 = generate_external_group_id();
176        let id2 = generate_external_group_id();
177        assert_eq!(id1.len(), RECOMMENDED_EXTERNAL_GROUP_ID_LEN);
178        assert_ne!(
179            id1, id2,
180            "two CSPRNG-generated external_group_ids must differ"
181        );
182    }
183
184    #[xmtp_common::test(unwrap_try = true)]
185    fn validate_accepts_well_formed_v1() {
186        let payload = well_formed_payload();
187        let v1 = validate(&payload)?;
188        assert_eq!(v1.symmetric_key.len(), SYMMETRIC_KEY_LEN);
189        assert!(v1.external_group_id.len() >= MIN_EXTERNAL_GROUP_ID_LEN);
190    }
191
192    #[xmtp_common::test(unwrap_try = true)]
193    fn validate_rejects_missing_version() {
194        let payload = ExternalInvitePayload { version: None };
195        assert_eq!(
196            validate(&payload),
197            Err(InvitePayloadError::UnsupportedVersion)
198        );
199    }
200
201    #[xmtp_common::test(unwrap_try = true)]
202    fn build_payload_rejects_short_external_group_id() {
203        let result = build_payload(
204            b"svc".to_vec(),
205            vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN - 1],
206            [0x42u8; SYMMETRIC_KEY_LEN],
207        );
208        assert_eq!(
209            result,
210            Err(InvitePayloadError::InvalidExternalGroupIdLength {
211                len: MIN_EXTERNAL_GROUP_ID_LEN - 1
212            })
213        );
214    }
215
216    #[xmtp_common::test(unwrap_try = true)]
217    fn validate_rejects_short_external_group_id_from_wire() {
218        // `build_payload` rejects too-short ids at construction; a wire payload
219        // hand-crafted (bypassing `build_payload`) still needs to be caught at
220        // validate time — defense-in-depth for receivers consuming bytes from
221        // untrusted peers.
222        let payload = ExternalInvitePayload {
223            version: Some(ExternalInvitePayloadVersion::V1(ExternalInvitePayloadV1 {
224                service_pointer: b"svc".to_vec(),
225                external_group_id: vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN - 1],
226                symmetric_key: vec![0x42u8; SYMMETRIC_KEY_LEN],
227            })),
228        };
229        assert_eq!(
230            validate(&payload),
231            Err(InvitePayloadError::InvalidExternalGroupIdLength {
232                len: MIN_EXTERNAL_GROUP_ID_LEN - 1
233            })
234        );
235    }
236
237    #[xmtp_common::test(unwrap_try = true)]
238    fn validate_rejects_wrong_symmetric_key_length() {
239        let mut payload = well_formed_payload();
240        if let Some(ExternalInvitePayloadVersion::V1(ref mut v1)) = payload.version {
241            v1.symmetric_key = vec![0u8; SYMMETRIC_KEY_LEN - 1];
242        }
243        assert_eq!(
244            validate(&payload),
245            Err(InvitePayloadError::InvalidSymmetricKeyLength(
246                SYMMETRIC_KEY_LEN - 1
247            ))
248        );
249    }
250
251    #[xmtp_common::test(unwrap_try = true)]
252    fn build_payload_round_trip() {
253        let service_pointer = b"https://invites.example/abc".to_vec();
254        let external_group_id = generate_external_group_id().to_vec();
255        let key = [0x42u8; SYMMETRIC_KEY_LEN];
256
257        let payload = build_payload(service_pointer.clone(), external_group_id.clone(), key)?;
258        let v1 = validate(&payload)?;
259        assert_eq!(v1.service_pointer, service_pointer);
260        assert_eq!(v1.external_group_id, external_group_id);
261        assert_eq!(v1.symmetric_key, key.to_vec());
262    }
263}