Skip to main content

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
148// implements: META-011
149impl Serialize for InboxId {
150    #[inline]
151    fn tls_serialize<W: Write>(&self, writer: &mut W) -> Result<usize, tls_codec::Error> {
152        // The version goes on the wire as a QUIC varint — the same
153        // encoding tls_codec uses internally for collection length
154        // prefixes. For version 0 this is exactly one byte (`0x00`).
155        let v_len = tls_codec::vlen::write_length(writer, INBOX_ID_VERSION as usize)?;
156        writer
157            .write_all(&self.0)
158            .map_err(|e| tls_codec::Error::EncodingError(e.to_string()))?;
159        Ok(v_len + INBOX_ID_BYTE_LEN)
160    }
161}
162
163// implements: META-011
164impl Deserialize for InboxId {
165    #[inline]
166    fn tls_deserialize<R: Read>(bytes: &mut R) -> Result<Self, tls_codec::Error>
167    where
168        Self: Sized,
169    {
170        let (version, consumed) = tls_codec::vlen::read_length(bytes)?;
171        // Two guards, belt-and-suspenders:
172        //
173        // 1. `consumed == 1` — for `INBOX_ID_VERSION = 0` the varint on the
174        //    wire is exactly one byte (`0x00`). Any multi-byte varint is
175        //    definitively not v0, regardless of the decoded numeric value.
176        //    This closes a 32-bit-target concern: `read_length` returns
177        //    `usize`, and a peer-sent QUIC varint encoding a value larger
178        //    than `usize::MAX` on wasm32 could be truncated by the decoder
179        //    to a value that happens to be 0. Checking the consumed byte
180        //    count sidesteps any reliance on upstream overflow handling.
181        //
182        // 2. `version as u64 == INBOX_ID_VERSION` — the semantic check,
183        //    kept as a defensive duplicate. The `as u64` is a widening
184        //    cast (lossless), not the truncating cast it might look like.
185        //
186        // When a future `INBOX_ID_VERSION` no longer fits in a single
187        // varint byte (i.e., > 0x3f), guard 1 must be updated in step
188        // with the new constant.
189        if consumed != 1 || version as u64 != INBOX_ID_VERSION {
190            return Err(tls_codec::Error::DecodingError(format!(
191                "unsupported InboxId version: varint={version}, bytes_consumed={consumed}"
192            )));
193        }
194        let mut buf = [0u8; INBOX_ID_BYTE_LEN];
195        bytes
196            .read_exact(&mut buf)
197            .map_err(|e| tls_codec::Error::DecodingError(e.to_string()))?;
198        Ok(Self(buf))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use proptest::prelude::*;
206    use tls_codec::{Deserialize, Serialize};
207
208    // --- Fixed-shape tests ---------------------------------------------------
209    //
210    // Pin the wire format concretely: a future refactor that accidentally
211    // swaps the version byte, the byte order, or the payload length would
212    // break these regardless of what random inputs the proptests generate.
213
214    #[xmtp_common::test]
215    // verifies: META-011
216    fn test_tls_serialize_writes_version_prefix_then_payload() {
217        let id = InboxId::from_bytes([0xAB; 32]);
218        let bytes = id.tls_serialize_detached().unwrap();
219        assert_eq!(bytes.len(), 33);
220        // QUIC varint for 0 is a single `0x00` byte.
221        assert_eq!(bytes[0], 0x00);
222        assert_eq!(&bytes[1..], &[0xAB; 32]);
223    }
224
225    #[xmtp_common::test]
226    fn test_from_hex_non_hex_input() {
227        let err = InboxId::from_hex("not_hex").unwrap_err();
228        assert!(matches!(err, InboxIdError::InvalidHex(_)));
229    }
230
231    /// A multi-byte QUIC varint that decodes to the value `0` must still be
232    /// rejected: v0 is defined as a single-byte `0x00` varint, so a 2-byte
233    /// form (`0x40 0x00`) or 4/8-byte forms are non-minimal encodings and
234    /// not valid v0 on the wire. Without the `consumed == 1` guard, a
235    /// 32-bit-target `read_length` implementation that truncates a large
236    /// varint to `usize::MAX`-fits-mod-0 could spoof v0.
237    ///
238    /// Two rejection paths are accepted because tls_codec's own
239    /// minimality check (`check_min_length`) only runs when the `mls`
240    /// feature is enabled: with the feature on, tls_codec surfaces
241    /// `InvalidVectorLength` before we see the decode; with it off, our
242    /// `consumed == 1` guard surfaces `DecodingError`.
243    #[xmtp_common::test]
244    // verifies: META-011
245    fn test_tls_deserialize_rejects_non_minimal_version_zero() {
246        // 2-byte QUIC varint: prefix 0b01 → `0x40 0x00` decodes to 0.
247        let mut bytes = vec![0x40, 0x00];
248        bytes.extend_from_slice(&[0xAB; INBOX_ID_BYTE_LEN]);
249        let err = InboxId::tls_deserialize_exact(&bytes).unwrap_err();
250        assert!(
251            matches!(
252                err,
253                tls_codec::Error::DecodingError(_) | tls_codec::Error::InvalidVectorLength
254            ),
255            "got {err:?}"
256        );
257    }
258
259    // --- Property tests ------------------------------------------------------
260
261    proptest! {
262        /// Round-tripping through hex is lossless for every 32-byte input.
263        #[test]
264        fn hex_round_trip(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
265            let id = InboxId::from_bytes(raw);
266            let hex = id.to_hex();
267            prop_assert_eq!(hex.len(), 2 * INBOX_ID_BYTE_LEN);
268            let back = InboxId::from_hex(&hex).unwrap();
269            prop_assert_eq!(id, back);
270        }
271
272        /// Round-tripping through TLS codec is lossless for every 32-byte input.
273        #[test]
274        fn tls_round_trip(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
275            let id = InboxId::from_bytes(raw);
276            let bytes = id.tls_serialize_detached().unwrap();
277            let restored = InboxId::tls_deserialize_exact(&bytes).unwrap();
278            prop_assert_eq!(id, restored);
279        }
280
281        /// `tls_serialized_len` matches the actual serialized byte count.
282        /// Pins the hardcoded [`INBOX_ID_V0_SERIALIZED_LEN`] against the
283        /// real encoding — if tls_codec's varint sizing ever changes, or
284        /// a future version bumps the prefix to multi-byte, this catches
285        /// the drift immediately.
286        #[test]
287        fn size_matches_serialized_bytes(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
288            let id = InboxId::from_bytes(raw);
289            let bytes = id.tls_serialize_detached().unwrap();
290            prop_assert_eq!(bytes.len(), id.tls_serialized_len());
291            prop_assert_eq!(bytes.len(), INBOX_ID_V0_SERIALIZED_LEN);
292        }
293
294        /// Hex-decoding any 64-char valid-hex string yields the same bytes
295        /// as direct construction.
296        #[test]
297        fn hex_and_from_bytes_agree(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
298            let via_bytes = InboxId::from_bytes(raw);
299            let via_hex = InboxId::from_hex(&hex::encode(raw)).unwrap();
300            prop_assert_eq!(via_bytes, via_hex);
301            prop_assert_eq!(via_bytes.as_bytes(), &raw);
302        }
303
304        /// `InboxId` ordering follows lexicographic byte ordering — the
305        /// contract TlsSet/TlsMap rely on for deterministic serialization.
306        #[test]
307        fn ord_matches_byte_order(
308            a in any::<[u8; INBOX_ID_BYTE_LEN]>(),
309            b in any::<[u8; INBOX_ID_BYTE_LEN]>(),
310        ) {
311            let ia = InboxId::from_bytes(a);
312            let ib = InboxId::from_bytes(b);
313            prop_assert_eq!(ia.cmp(&ib), a.cmp(&b));
314        }
315
316        /// Any hex string whose decoded byte length isn't 32 is rejected
317        /// with [`InboxIdError::InvalidLength`]. The even-length constraint
318        /// on `len` keeps the hex string valid — we're exercising the
319        /// length check, not the hex parser.
320        #[test]
321        fn from_hex_rejects_wrong_length(byte_len in (0usize..=64).prop_filter(
322            "byte_len must be != INBOX_ID_BYTE_LEN",
323            |n| *n != INBOX_ID_BYTE_LEN,
324        )) {
325            // 2 hex chars per byte — `byte_len` bytes of hex input.
326            let s = "ab".repeat(byte_len);
327            let err = InboxId::from_hex(&s).unwrap_err();
328            let matched = match err {
329                InboxIdError::InvalidLength { expected, actual } =>
330                    expected == INBOX_ID_BYTE_LEN && actual == byte_len,
331                _ => false,
332            };
333            prop_assert!(matched);
334        }
335
336        /// Any varint-encoded version byte other than 0 is rejected by the
337        /// deserializer. Sweeping the single-byte varint range (0..=0x3f)
338        /// and skipping 0 covers every v0 rejection path reachable without
339        /// varint-encoder gymnastics.
340        #[test]
341        fn tls_deserialize_rejects_unsupported_version(v in 1u8..=0x3f) {
342            let mut bytes = vec![v];
343            bytes.extend_from_slice(&[0xFF; INBOX_ID_BYTE_LEN]);
344            let err = InboxId::tls_deserialize_exact(&bytes).unwrap_err();
345            prop_assert!(matches!(err, tls_codec::Error::DecodingError(_)));
346        }
347
348        /// Debug and Display both produce exactly the hex form with no
349        /// trailing allocation-smells — and Debug wraps Display inside
350        /// `InboxId(...)`.
351        #[test]
352        fn formatting_is_hex(raw in any::<[u8; INBOX_ID_BYTE_LEN]>()) {
353            let id = InboxId::from_bytes(raw);
354            let displayed = format!("{id}");
355            let debugged = format!("{id:?}");
356            prop_assert_eq!(displayed.clone(), hex::encode(raw));
357            prop_assert_eq!(debugged, format!("InboxId({displayed})"));
358        }
359
360        /// `TlsSet<InboxId>` round-trips through tls_codec for any set of
361        /// random 32-byte entries.
362        #[test]
363        fn tls_set_round_trip(
364            raws in proptest::collection::btree_set(
365                any::<[u8; INBOX_ID_BYTE_LEN]>(),
366                0..16,
367            ),
368        ) {
369            use crate::tls_set::TlsSet;
370
371            let set: TlsSet<InboxId> =
372                raws.iter().copied().map(InboxId::from_bytes).collect();
373            let bytes = set.tls_serialize_detached().unwrap();
374            let restored = TlsSet::<InboxId>::tls_deserialize_exact(&bytes).unwrap();
375            prop_assert_eq!(set, restored);
376        }
377    }
378}