|
| 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 | +} |
0 commit comments