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
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}