xmtp_mls_common/inbox_id.rs
1#![deny(missing_docs)]
2
3//! A versioned, type-safe wrapper around a raw XMTP inbox id.
4//!
5//! ## Why a newtype instead of `[u8; 32]`?
6//!
7//! Plenty of 32-byte values fly around the codebase — installation ids,
8//! commit hashes, TLS key hashes. Using `[u8; 32]` for inbox ids loses
9//! the compiler's ability to catch accidental mixing, and forces every
10//! site that serializes an inbox id to re-derive the wire format from
11//! first principles.
12//!
13//! [`InboxId`] replaces the raw array everywhere an inbox id flows
14//! through the AppData-dictionary wire format.
15//!
16//! ## Wire format
17//!
18//! `varint(version) || version_specific_payload`
19//!
20//! The version uses the QUIC variable-length integer encoding from RFC
21//! 9000 §16 — the same scheme TLS-codec uses for collection length
22//! prefixes. Version 0 fits in a single byte (`0x00`), so v0 `InboxId`
23//! values are **33 bytes on the wire** (1 varint + 32 raw bytes).
24//!
25//! - **Version 0:** `32` raw bytes — the SHA-256 hash backing the
26//! hex-encoded string form (see `xmtp_id::associations::member::inbox_id`).
27//!
28//! Future versions can encode completely different payload shapes
29//! (longer ids, different cryptographic schemes, …) without disturbing
30//! on-the-wire compatibility of existing values.
31
32use std::io::{Read, Write};
33
34use tls_codec::{Deserialize, Serialize, Size};
35
36/// Length in raw bytes of a v0 XMTP inbox id (the SHA-256 hash that
37/// backs the hex-encoded string form).
38pub const INBOX_ID_BYTE_LEN: usize = 32;
39
40/// Current wire-format version written by [`InboxId::tls_serialize`].
41pub const INBOX_ID_VERSION: u64 = 0;
42
43/// Errors surfaced by [`InboxId`] construction from hex strings or
44/// `Vec<u8>`.
45#[derive(Debug, thiserror::Error)]
46pub enum InboxIdError {
47 /// The input wasn't valid hex at all (non-hex characters, odd
48 /// length, etc.).
49 #[error("invalid inbox id (hex decode): {0}")]
50 InvalidHex(#[from] hex::FromHexError),
51 /// The input was valid hex (or raw bytes) but the decoded byte
52 /// length didn't match [`INBOX_ID_BYTE_LEN`].
53 #[error("invalid inbox id length: expected {expected}, got {actual}")]
54 InvalidLength {
55 /// Expected length in raw bytes ([`INBOX_ID_BYTE_LEN`]).
56 expected: usize,
57 /// Actual length the caller supplied.
58 actual: usize,
59 },
60}
61
62/// A type-safe XMTP inbox id.
63///
64/// Backed by a `[u8; 32]` (v0 wire format). On the wire it is encoded
65/// as `varint(version) || payload`; see the module-level docs for the
66/// full wire-format contract.
67#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct InboxId([u8; INBOX_ID_BYTE_LEN]);
69
70impl InboxId {
71 /// Wrap a raw 32-byte inbox id.
72 #[inline]
73 pub const fn from_bytes(bytes: [u8; INBOX_ID_BYTE_LEN]) -> Self {
74 Self(bytes)
75 }
76
77 /// Borrow the raw 32-byte payload.
78 #[inline]
79 pub const fn as_bytes(&self) -> &[u8; INBOX_ID_BYTE_LEN] {
80 &self.0
81 }
82
83 /// Consume the wrapper and return the raw 32-byte payload.
84 #[inline]
85 pub const fn into_bytes(self) -> [u8; INBOX_ID_BYTE_LEN] {
86 self.0
87 }
88
89 /// Decode a 64-character hex inbox id string into an [`InboxId`].
90 pub fn from_hex(s: &str) -> Result<Self, InboxIdError> {
91 let raw = hex::decode(s)?;
92 let bytes: [u8; INBOX_ID_BYTE_LEN] =
93 raw.try_into()
94 .map_err(|v: Vec<u8>| InboxIdError::InvalidLength {
95 expected: INBOX_ID_BYTE_LEN,
96 actual: v.len(),
97 })?;
98 Ok(Self(bytes))
99 }
100
101 /// Encode the raw bytes back to their canonical 64-character hex
102 /// string form.
103 pub fn to_hex(&self) -> String {
104 hex::encode(self.0)
105 }
106}
107
108impl std::fmt::Debug for InboxId {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.write_str("InboxId(")?;
111 for byte in &self.0 {
112 write!(f, "{byte:02x}")?;
113 }
114 f.write_str(")")
115 }
116}
117
118impl std::fmt::Display for InboxId {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 for byte in &self.0 {
121 write!(f, "{byte:02x}")?;
122 }
123 Ok(())
124 }
125}
126
127/// Wire-format size of a v0 [`InboxId`].
128///
129/// The version prefix is a QUIC varint. For `INBOX_ID_VERSION = 0` it is
130/// exactly one byte (`0x00`), so the total encoded length is fixed at
131/// `1 + 32 = 33`. We pin this as a plain constant rather than reproduce
132/// `tls_codec::quic_vec::length_encoding_bytes` (which isn't part of the
133/// public API); the [`tests::size_matches_serialized_bytes`] proptest
134/// guarantees the constant stays in sync with the actual serialized
135/// bytes.
136///
137/// When a future [`INBOX_ID_VERSION`] no longer fits in a single
138/// varint byte, this constant must be revisited.
139const INBOX_ID_V0_SERIALIZED_LEN: usize = 1 + INBOX_ID_BYTE_LEN;
140
141impl Size for InboxId {
142 #[inline]
143 fn tls_serialized_len(&self) -> usize {
144 INBOX_ID_V0_SERIALIZED_LEN
145 }
146}
147
148impl Serialize for InboxId {
149 #[inline]
150 fn tls_serialize<W: Write>(&self, writer: &mut W) -> Result<usize, tls_codec::Error> {
151 // The version goes on the wire as a QUIC varint — the same
152 // encoding tls_codec uses internally for collection length
153 // prefixes. For version 0 this is exactly one byte (`0x00`).
154 let v_len = tls_codec::vlen::write_length(writer, INBOX_ID_VERSION as usize)?;
155 writer
156 .write_all(&self.0)
157 .map_err(|e| tls_codec::Error::EncodingError(e.to_string()))?;
158 Ok(v_len + INBOX_ID_BYTE_LEN)
159 }
160}
161
162impl Deserialize for InboxId {
163 #[inline]
164 fn tls_deserialize<R: Read>(bytes: &mut R) -> Result<Self, tls_codec::Error>
165 where
166 Self: Sized,
167 {
168 let (version, consumed) = tls_codec::vlen::read_length(bytes)?;
169 // Two guards, belt-and-suspenders:
170 //
171 // 1. `consumed == 1` — for `INBOX_ID_VERSION = 0` the varint on the
172 // wire is exactly one byte (`0x00`). Any multi-byte varint is
173 // definitively not v0, regardless of the decoded numeric value.
174 // This closes a 32-bit-target concern: `read_length` returns
175 // `usize`, and a peer-sent QUIC varint encoding a value larger
176 // than `usize::MAX` on wasm32 could be truncated by the decoder
177 // to a value that happens to be 0. Checking the consumed byte
178 // count sidesteps any reliance on upstream overflow handling.
179 //
180 // 2. `version as u64 == INBOX_ID_VERSION` — the semantic check,
181 // kept as a defensive duplicate. The `as u64` is a widening
182 // cast (lossless), not the truncating cast it might look like.
183 //
184 // When a future `INBOX_ID_VERSION` no longer fits in a single
185 // varint byte (i.e., > 0x3f), guard 1 must be updated in step
186 // with the new constant.
187 if consumed != 1 || version as u64 != INBOX_ID_VERSION {
188 return Err(tls_codec::Error::DecodingError(format!(
189 "unsupported InboxId version: varint={version}, bytes_consumed={consumed}"
190 )));
191 }
192 let mut buf = [0u8; INBOX_ID_BYTE_LEN];
193 bytes
194 .read_exact(&mut buf)
195 .map_err(|e| tls_codec::Error::DecodingError(e.to_string()))?;
196 Ok(Self(buf))
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use proptest::prelude::*;
204 use tls_codec::{Deserialize, Serialize};
205
206 // --- Fixed-shape tests ---------------------------------------------------
207 //
208 // Pin the wire format concretely: a future refactor that accidentally
209 // swaps the version byte, the byte order, or the payload length would
210 // break these regardless of what random inputs the proptests generate.
211
212 #[xmtp_common::test]
213 fn test_tls_serialize_writes_version_prefix_then_payload() {
214 let id = InboxId::from_bytes([0xAB; 32]);
215 let bytes = id.tls_serialize_detached().unwrap();
216 assert_eq!(bytes.len(), 33);
217 // QUIC varint for 0 is a single `0x00` byte.
218 assert_eq!(bytes[0], 0x00);
219 assert_eq!(&bytes[1..], &[0xAB; 32]);
220 }
221
222 #[xmtp_common::test]
223 fn test_from_hex_non_hex_input() {
224 let err = InboxId::from_hex("not_hex").unwrap_err();
225 assert!(matches!(err, InboxIdError::InvalidHex(_)));
226 }
227
228 /// A multi-byte QUIC varint that decodes to the value `0` must still be
229 /// rejected: v0 is defined as a single-byte `0x00` varint, so a 2-byte
230 /// form (`0x40 0x00`) or 4/8-byte forms are non-minimal encodings and
231 /// not valid v0 on the wire. Without the `consumed == 1` guard, a
232 /// 32-bit-target `read_length` implementation that truncates a large
233 /// varint to `usize::MAX`-fits-mod-0 could spoof v0.
234 ///
235 /// Two rejection paths are accepted because tls_codec's own
236 /// minimality check (`check_min_length`) only runs when the `mls`
237 /// feature is enabled: with the feature on, tls_codec surfaces
238 /// `InvalidVectorLength` before we see the decode; with it off, our
239 /// `consumed == 1` guard surfaces `DecodingError`.
240 #[xmtp_common::test]
241 fn test_tls_deserialize_rejects_non_minimal_version_zero() {
242 // 2-byte QUIC varint: prefix 0b01 → `0x40 0x00` decodes to 0.
243 let mut bytes = vec![0x40, 0x00];
244 bytes.extend_from_slice(&[0xAB; INBOX_ID_BYTE_LEN]);
245 let err = InboxId::tls_deserialize_exact(&bytes).unwrap_err();
246 assert!(
247 matches!(
248 err,
249 tls_codec::Error::DecodingError(_) | tls_codec::Error::InvalidVectorLength
250 ),
251 "got {err:?}"
252 );
253 }
254
255 // --- Property tests ------------------------------------------------------
256
257 proptest! {
258 /// Round-tripping through hex is lossless for every 32-byte input.
259 #[test]
260 fn hex_round_trip(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
261 let id = InboxId::from_bytes(raw);
262 let hex = id.to_hex();
263 prop_assert_eq!(hex.len(), 2 * INBOX_ID_BYTE_LEN);
264 let back = InboxId::from_hex(&hex).unwrap();
265 prop_assert_eq!(id, back);
266 }
267
268 /// Round-tripping through TLS codec is lossless for every 32-byte input.
269 #[test]
270 fn tls_round_trip(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
271 let id = InboxId::from_bytes(raw);
272 let bytes = id.tls_serialize_detached().unwrap();
273 let restored = InboxId::tls_deserialize_exact(&bytes).unwrap();
274 prop_assert_eq!(id, restored);
275 }
276
277 /// `tls_serialized_len` matches the actual serialized byte count.
278 /// Pins the hardcoded [`INBOX_ID_V0_SERIALIZED_LEN`] against the
279 /// real encoding — if tls_codec's varint sizing ever changes, or
280 /// a future version bumps the prefix to multi-byte, this catches
281 /// the drift immediately.
282 #[test]
283 fn size_matches_serialized_bytes(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
284 let id = InboxId::from_bytes(raw);
285 let bytes = id.tls_serialize_detached().unwrap();
286 prop_assert_eq!(bytes.len(), id.tls_serialized_len());
287 prop_assert_eq!(bytes.len(), INBOX_ID_V0_SERIALIZED_LEN);
288 }
289
290 /// Hex-decoding any 64-char valid-hex string yields the same bytes
291 /// as direct construction.
292 #[test]
293 fn hex_and_from_bytes_agree(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
294 let via_bytes = InboxId::from_bytes(raw);
295 let via_hex = InboxId::from_hex(&hex::encode(raw)).unwrap();
296 prop_assert_eq!(via_bytes, via_hex);
297 prop_assert_eq!(via_bytes.as_bytes(), &raw);
298 }
299
300 /// `InboxId` ordering follows lexicographic byte ordering — the
301 /// contract TlsSet/TlsMap rely on for deterministic serialization.
302 #[test]
303 fn ord_matches_byte_order(
304 a in any::<[u8; INBOX_ID_BYTE_LEN]>(),
305 b in any::<[u8; INBOX_ID_BYTE_LEN]>(),
306 ) {
307 let ia = InboxId::from_bytes(a);
308 let ib = InboxId::from_bytes(b);
309 prop_assert_eq!(ia.cmp(&ib), a.cmp(&b));
310 }
311
312 /// Any hex string whose decoded byte length isn't 32 is rejected
313 /// with [`InboxIdError::InvalidLength`]. The even-length constraint
314 /// on `len` keeps the hex string valid — we're exercising the
315 /// length check, not the hex parser.
316 #[test]
317 fn from_hex_rejects_wrong_length(byte_len in (0usize..=64).prop_filter(
318 "byte_len must be != INBOX_ID_BYTE_LEN",
319 |n| *n != INBOX_ID_BYTE_LEN,
320 )) {
321 // 2 hex chars per byte — `byte_len` bytes of hex input.
322 let s = "ab".repeat(byte_len);
323 let err = InboxId::from_hex(&s).unwrap_err();
324 let matched = match err {
325 InboxIdError::InvalidLength { expected, actual } =>
326 expected == INBOX_ID_BYTE_LEN && actual == byte_len,
327 _ => false,
328 };
329 prop_assert!(matched);
330 }
331
332 /// Any varint-encoded version byte other than 0 is rejected by the
333 /// deserializer. Sweeping the single-byte varint range (0..=0x3f)
334 /// and skipping 0 covers every v0 rejection path reachable without
335 /// varint-encoder gymnastics.
336 #[test]
337 fn tls_deserialize_rejects_unsupported_version(v in 1u8..=0x3f) {
338 let mut bytes = vec![v];
339 bytes.extend_from_slice(&[0xFF; INBOX_ID_BYTE_LEN]);
340 let err = InboxId::tls_deserialize_exact(&bytes).unwrap_err();
341 prop_assert!(matches!(err, tls_codec::Error::DecodingError(_)));
342 }
343
344 /// Debug and Display both produce exactly the hex form with no
345 /// trailing allocation-smells — and Debug wraps Display inside
346 /// `InboxId(...)`.
347 #[test]
348 fn formatting_is_hex(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
349 let id = InboxId::from_bytes(raw);
350 let displayed = format!("{id}");
351 let debugged = format!("{id:?}");
352 prop_assert_eq!(displayed.clone(), hex::encode(raw));
353 prop_assert_eq!(debugged, format!("InboxId({displayed})"));
354 }
355
356 /// `TlsSet<InboxId>` round-trips through tls_codec for any set of
357 /// random 32-byte entries.
358 #[test]
359 fn tls_set_round_trip(
360 raws in proptest::collection::btree_set(
361 any::<[u8; INBOX_ID_BYTE_LEN]>(),
362 0..16,
363 ),
364 ) {
365 use crate::tls_set::TlsSet;
366
367 let set: TlsSet<InboxId> =
368 raws.iter().copied().map(InboxId::from_bytes).collect();
369 let bytes = set.tls_serialize_detached().unwrap();
370 let restored = TlsSet::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
371 prop_assert_eq!(set, restored);
372 }
373 }
374}