Skip to content

Commit ce61b41

Browse files
committed
xmtp_mls_common: add invite::payload module (ExternalInvitePayload helpers)
1 parent d5fb766 commit ce61b41

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! External-invite primitives for QR-code-based group joining via MLS external
2+
//! commits.
3+
//!
4+
//! The [`payload`] module provides helpers for the
5+
//! [`ExternalInvitePayload`] proto. The encryption envelope lives in the
6+
//! sibling `encrypted_group_info` module (added by a separate PR).
7+
//!
8+
//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload
9+
10+
pub mod payload;
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
//! Helpers for the [`ExternalInvitePayload`] proto.
2+
//!
3+
//! This module centralises the small but easy-to-get-wrong pieces of building
4+
//! and validating an external-invite payload:
5+
//!
6+
//! * fresh symmetric keys / nonces from the workspace CSPRNG
7+
//! * the canonical `sha256(group_id)` lookup hash
8+
//! * version gating against [`CURRENT_PAYLOAD_VERSION`]
9+
//! * expiry checks with the `0 == never` convention
10+
//! * a [`build_payload`] convenience constructor
11+
//!
12+
//! The actual encryption of the [`GroupInfo`] blob is performed by the
13+
//! sibling `encrypted_group_info` module (added in a follow-up PR).
14+
//!
15+
//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload
16+
//! [`GroupInfo`]: openmls::messages::group_info::GroupInfo
17+
18+
use thiserror::Error;
19+
use xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload;
20+
21+
/// Wire-format version implemented by this module. Payloads with any other
22+
/// `version` field are rejected by [`validate_version`].
23+
pub const CURRENT_PAYLOAD_VERSION: u32 = 1;
24+
25+
/// Length in bytes of the ChaCha20Poly1305 key used to wrap the encrypted
26+
/// `GroupInfo` blob referenced by an [`ExternalInvitePayload`].
27+
pub const SYMMETRIC_KEY_LEN: usize = 32;
28+
29+
/// Length in bytes of the ChaCha20Poly1305 nonce used alongside
30+
/// [`SYMMETRIC_KEY_LEN`]-byte keys.
31+
pub const NONCE_LEN: usize = 12;
32+
33+
/// Errors returned when validating the `version` field of an
34+
/// [`ExternalInvitePayload`].
35+
#[derive(Debug, Error, PartialEq, Eq)]
36+
pub enum InviteVersionError {
37+
/// The payload advertises a version this build does not understand. The
38+
/// `found` value is whatever the wire said; `expected` is
39+
/// [`CURRENT_PAYLOAD_VERSION`].
40+
#[error("unsupported external-invite payload version: found {found}, expected {expected}")]
41+
UnsupportedVersion {
42+
/// Version advertised by the payload.
43+
found: u32,
44+
/// Version this build understands.
45+
expected: u32,
46+
},
47+
}
48+
49+
/// Generate a fresh 32-byte symmetric key from the workspace CSPRNG.
50+
///
51+
/// The key is intended for use with ChaCha20Poly1305 when wrapping the
52+
/// encrypted GroupInfo blob referenced by the resulting
53+
/// [`ExternalInvitePayload`].
54+
pub fn generate_symmetric_key() -> [u8; SYMMETRIC_KEY_LEN] {
55+
xmtp_common::rand_array::<SYMMETRIC_KEY_LEN>()
56+
}
57+
58+
/// Generate a fresh 12-byte nonce from the workspace CSPRNG.
59+
///
60+
/// Intended for use with ChaCha20Poly1305 alongside a key produced by
61+
/// [`generate_symmetric_key`]. The nonce is *not* stored in the payload
62+
/// itself — it lives next to the ciphertext in the encrypted GroupInfo blob.
63+
pub fn generate_nonce() -> [u8; NONCE_LEN] {
64+
xmtp_common::rand_array::<NONCE_LEN>()
65+
}
66+
67+
/// Compute the canonical lookup hash `sha256(group_id)` used as the
68+
/// `group_id_hash` field of an [`ExternalInvitePayload`].
69+
///
70+
/// External invite services key the encrypted GroupInfo blob by this hash so
71+
/// that callers can fetch the blob without revealing the underlying
72+
/// `group_id`.
73+
pub fn compute_group_id_hash(group_id: &[u8]) -> [u8; 32] {
74+
xmtp_common::sha256_array(group_id)
75+
}
76+
77+
/// Returns `true` if `payload` carries a non-zero expiry that is at or before
78+
/// `now_ns`.
79+
///
80+
/// `expires_at_ns == 0` is treated as "no expiry" per the proto contract.
81+
/// The comparison is `>=`, so `now_ns == expires_at_ns` is considered
82+
/// expired.
83+
pub fn is_expired(payload: &ExternalInvitePayload, now_ns: u64) -> bool {
84+
payload.expires_at_ns != 0 && now_ns >= payload.expires_at_ns
85+
}
86+
87+
/// Validate that `payload.version` matches [`CURRENT_PAYLOAD_VERSION`].
88+
///
89+
/// Returns [`InviteVersionError::UnsupportedVersion`] for every other value
90+
/// (including `0`).
91+
pub fn validate_version(payload: &ExternalInvitePayload) -> Result<(), InviteVersionError> {
92+
if payload.version == CURRENT_PAYLOAD_VERSION {
93+
Ok(())
94+
} else {
95+
Err(InviteVersionError::UnsupportedVersion {
96+
found: payload.version,
97+
expected: CURRENT_PAYLOAD_VERSION,
98+
})
99+
}
100+
}
101+
102+
/// Build an [`ExternalInvitePayload`] with `version` set to
103+
/// [`CURRENT_PAYLOAD_VERSION`] and `group_id_hash` derived via
104+
/// [`compute_group_id_hash`].
105+
///
106+
/// * `service_pointer` — application-defined opaque bytes describing where
107+
/// the encrypted GroupInfo blob can be fetched.
108+
/// * `group_id` — the raw MLS group id; hashed in place.
109+
/// * `symmetric_key` — typically the output of [`generate_symmetric_key`].
110+
/// * `expires_at_ns` — nanoseconds since the UNIX epoch; `0` means no expiry.
111+
/// * `ciphersuite` — the MLS ciphersuite identifier of the target group.
112+
pub fn build_payload(
113+
service_pointer: Vec<u8>,
114+
group_id: &[u8],
115+
symmetric_key: [u8; SYMMETRIC_KEY_LEN],
116+
expires_at_ns: u64,
117+
ciphersuite: u16,
118+
) -> ExternalInvitePayload {
119+
ExternalInvitePayload {
120+
version: CURRENT_PAYLOAD_VERSION,
121+
service_pointer,
122+
group_id_hash: compute_group_id_hash(group_id).to_vec(),
123+
symmetric_key: symmetric_key.to_vec(),
124+
expires_at_ns,
125+
ciphersuite: ciphersuite as u32,
126+
}
127+
}
128+
129+
#[cfg(test)]
130+
mod tests {
131+
use super::*;
132+
133+
#[xmtp_common::test(unwrap_try = true)]
134+
fn group_id_hash_is_sha256() {
135+
// Known SHA-256 of the empty string.
136+
let empty = compute_group_id_hash(b"");
137+
let expected_empty: [u8; 32] = [
138+
0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f,
139+
0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b,
140+
0x78, 0x52, 0xb8, 0x55,
141+
];
142+
assert_eq!(empty, expected_empty);
143+
144+
// Known SHA-256 of "abc".
145+
let abc = compute_group_id_hash(b"abc");
146+
let expected_abc: [u8; 32] = [
147+
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
148+
0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
149+
0xf2, 0x00, 0x15, 0xad,
150+
];
151+
assert_eq!(abc, expected_abc);
152+
153+
// Deterministic for identical inputs.
154+
assert_eq!(compute_group_id_hash(b"abc"), compute_group_id_hash(b"abc"));
155+
}
156+
157+
#[xmtp_common::test(unwrap_try = true)]
158+
fn key_and_nonce_are_random() {
159+
let k1 = generate_symmetric_key();
160+
let k2 = generate_symmetric_key();
161+
assert_eq!(k1.len(), SYMMETRIC_KEY_LEN);
162+
assert_eq!(k2.len(), SYMMETRIC_KEY_LEN);
163+
assert_ne!(k1, k2, "two CSPRNG-generated keys must differ");
164+
assert_ne!(k1, [0u8; SYMMETRIC_KEY_LEN], "key must not be all-zero");
165+
166+
let n1 = generate_nonce();
167+
let n2 = generate_nonce();
168+
assert_eq!(n1.len(), NONCE_LEN);
169+
assert_eq!(n2.len(), NONCE_LEN);
170+
assert_ne!(n1, n2, "two CSPRNG-generated nonces must differ");
171+
assert_ne!(n1, [0u8; NONCE_LEN], "nonce must not be all-zero");
172+
}
173+
174+
#[xmtp_common::test(unwrap_try = true)]
175+
fn validate_version_accepts_current() {
176+
let payload = build_payload(b"svc".to_vec(), b"group-id", [7u8; SYMMETRIC_KEY_LEN], 0, 1);
177+
assert_eq!(payload.version, CURRENT_PAYLOAD_VERSION);
178+
validate_version(&payload)?;
179+
}
180+
181+
#[xmtp_common::test(unwrap_try = true)]
182+
fn validate_version_rejects_zero_and_future() {
183+
let mut payload =
184+
build_payload(b"svc".to_vec(), b"group-id", [7u8; SYMMETRIC_KEY_LEN], 0, 1);
185+
186+
payload.version = 0;
187+
assert_eq!(
188+
validate_version(&payload),
189+
Err(InviteVersionError::UnsupportedVersion {
190+
found: 0,
191+
expected: CURRENT_PAYLOAD_VERSION,
192+
})
193+
);
194+
195+
payload.version = CURRENT_PAYLOAD_VERSION + 1;
196+
assert_eq!(
197+
validate_version(&payload),
198+
Err(InviteVersionError::UnsupportedVersion {
199+
found: CURRENT_PAYLOAD_VERSION + 1,
200+
expected: CURRENT_PAYLOAD_VERSION,
201+
})
202+
);
203+
204+
payload.version = u32::MAX;
205+
assert_eq!(
206+
validate_version(&payload),
207+
Err(InviteVersionError::UnsupportedVersion {
208+
found: u32::MAX,
209+
expected: CURRENT_PAYLOAD_VERSION,
210+
})
211+
);
212+
}
213+
214+
#[xmtp_common::test(unwrap_try = true)]
215+
fn is_expired_handles_zero_as_no_expiry() {
216+
let payload = build_payload(b"svc".to_vec(), b"group-id", [7u8; SYMMETRIC_KEY_LEN], 0, 1);
217+
assert!(!is_expired(&payload, 0));
218+
assert!(!is_expired(&payload, u64::MAX));
219+
}
220+
221+
#[xmtp_common::test(unwrap_try = true)]
222+
fn is_expired_strict_inequality() {
223+
let payload = build_payload(
224+
b"svc".to_vec(),
225+
b"group-id",
226+
[7u8; SYMMETRIC_KEY_LEN],
227+
100,
228+
1,
229+
);
230+
// Before expiry.
231+
assert!(!is_expired(&payload, 0));
232+
assert!(!is_expired(&payload, 99));
233+
// At expiry — treated as expired (>=).
234+
assert!(is_expired(&payload, 100));
235+
// After expiry.
236+
assert!(is_expired(&payload, 101));
237+
assert!(is_expired(&payload, u64::MAX));
238+
}
239+
240+
#[xmtp_common::test(unwrap_try = true)]
241+
fn build_payload_round_trip() {
242+
let group_id = b"some-group-id";
243+
let service_pointer = b"https://invites.example/abc".to_vec();
244+
let key = [0x42u8; SYMMETRIC_KEY_LEN];
245+
let expires = 1_700_000_000_000_000_000u64;
246+
let ciphersuite: u16 = 0x0001;
247+
248+
let payload = build_payload(service_pointer.clone(), group_id, key, expires, ciphersuite);
249+
250+
assert_eq!(payload.version, CURRENT_PAYLOAD_VERSION);
251+
assert_eq!(payload.service_pointer, service_pointer);
252+
assert_eq!(
253+
payload.group_id_hash,
254+
compute_group_id_hash(group_id).to_vec()
255+
);
256+
assert_eq!(payload.symmetric_key, key.to_vec());
257+
assert_eq!(payload.expires_at_ns, expires);
258+
assert_eq!(payload.ciphersuite, ciphersuite as u32);
259+
260+
// The round-tripped payload still passes validation.
261+
validate_version(&payload)?;
262+
assert!(!is_expired(&payload, 0));
263+
}
264+
}

crates/xmtp_mls_common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ pub mod group;
33
pub mod group_metadata;
44
pub mod group_mutable_metadata;
55
pub mod inbox_id;
6+
pub mod invite;
67
pub mod tls_map;
78
pub mod tls_set;

0 commit comments

Comments
 (0)