Skip to main content

river_core/room_state/
dm_body.rs

1//! Direct-message **body** encoding — the structured payload that goes
2//! INSIDE the ECIES ciphertext of an [`AuthorizedDirectMessage`].
3//!
4//! [`AuthorizedDirectMessage`]: crate::room_state::direct_messages::AuthorizedDirectMessage
5//!
6//! # Why this lives here
7//!
8//! The room contract validates the OUTER envelope of a DM (sender
9//! signature, membership, caps, tombstones) but never reads the
10//! plaintext — that's opaque ECIES ciphertext only the recipient can
11//! decrypt. The body is therefore a pure client-↔-client concern: every
12//! River UI / `riverctl` client agrees on how to encode and decode the
13//! plaintext bytes, but the contract is indifferent. Putting this in
14//! `river-core` keeps UI + CLI byte-identical without dragging any
15//! contract WASM changes into the picture (i.e. no delegate / contract
16//! migration entry is required for adding a body variant).
17//!
18//! # Wire format
19//!
20//! ```text
21//!     magic_byte (0x80)           ( 1 byte)
22//!     cbor(DirectMessageBody)     (variable)
23//! ```
24//!
25//! `0x80` is a UTF-8 *continuation* byte and CANNOT appear as the first
26//! byte of any valid UTF-8 string. Pre-this-format DM plaintexts were
27//! always UTF-8 text (encoded via `String::into_bytes`), so any DM
28//! starting with `0x80` is unambiguously a new-format body. Bodies
29//! whose first byte is anything else are decoded as legacy
30//! [`DirectMessageBody::Text`] via lossy UTF-8 conversion.
31//!
32//! See [`encode_body`] / [`decode_body`] for the canonical wire
33//! transition, plus the unit tests below pinning round-trips and the
34//! legacy fallback path.
35//!
36//! # Why CBOR and not bincode
37//!
38//! CBOR is already in `river-core`'s dep graph (used by `Invitation`
39//! encoding in the UI), and is forwards-compatible with optional fields
40//! out of the box (`#[serde(default)]`). bincode would tighten the
41//! encoding a few bytes but would create a separate deserialization
42//! surface to evolve.
43//!
44//! # Adding a new variant
45//!
46//! 1. Add the variant at the END of [`DirectMessageBody`] so older
47//!    clients still decode the existing variants.
48//! 2. Existing test coverage (`encode_decode_*`, `legacy_text_decodes_as_text`,
49//!    plus your new round-trip test) pin the wire shape.
50//! 3. Update both [`crate::room_state::direct_messages::compose_direct_message`]
51//!    callers (UI + CLI) to produce the new variant when appropriate.
52
53use ed25519_dalek::VerifyingKey;
54use serde::{Deserialize, Serialize};
55
56/// First byte of every new-format DM body. `0x80` is a UTF-8 continuation
57/// byte and cannot appear as the leading byte of any valid UTF-8 string,
58/// so a legitimate legacy text body can never collide with this magic.
59pub const DM_BODY_MAGIC: u8 = 0x80;
60
61/// Structured plaintext payload of a direct message.
62///
63/// Round-trips through [`encode_body`] / [`decode_body`]. Legacy
64/// plaintext (any pre-this-format DM, which was raw UTF-8 bytes)
65/// decodes via [`decode_body`] as [`DirectMessageBody::Text`] using a
66/// lossy UTF-8 conversion — see [`decode_body`]'s documentation.
67///
68/// The `Invite` variant carries its inner fields in a boxed
69/// [`InvitePayload`] sub-struct so the enum's stack size doesn't blow
70/// up under `clippy::large_enum_variant` — `VerifyingKey` is 32 bytes
71/// plus alignment slack, plus an inline `Option<String>`, plus the
72/// heap-pointer of `invitation_payload`. Boxing keeps the enum
73/// discriminant + pointer small (16 bytes on 64-bit) regardless of
74/// which variant is active. Wire format is unaffected: ciborium
75/// encodes `Box<T>` exactly the same as `T`.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub enum DirectMessageBody {
78    /// Plain user-typed text. Equivalent in wire bytes to what was
79    /// previously the entire decrypted body.
80    Text {
81        #[serde(default)]
82        text: String,
83    },
84
85    /// Structured invite handed peer-to-peer via DM. The recipient
86    /// renders this as an "Invitation card" with an Accept button that
87    /// re-uses the URL-bar accept-invitation handler — no full page
88    /// reload required.
89    Invite(Box<InvitePayload>),
90}
91
92/// Inner shape of [`DirectMessageBody::Invite`].
93///
94/// `invitation_payload` is the CBOR-encoded
95/// `ui::components::members::Invitation` (the same bytes that get
96/// base58-encoded as the `?invitation=…` URL parameter). Encoding it as
97/// CBOR bytes here (rather than the base58 string form) saves the
98/// encode-decode round-trip on the recipient side and keeps the wire
99/// body smaller — base58 is a 1.37x expansion.
100///
101/// `room_owner_vk` redundantly carries the target room's owner key so
102/// a client can show a "you're invited to room X" affordance without
103/// first round-tripping the payload through Invitation decode. It MUST
104/// match what the payload's invitation deserialises to; the recipient
105/// SHOULD reject mismatches as malformed.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct InvitePayload {
108    /// Owner verifying-key of the target room. Cheap to inspect
109    /// without decoding `invitation_payload`.
110    pub room_owner_vk: VerifyingKey,
111    /// CBOR-encoded `Invitation` (room + invitee signing key +
112    /// authorised member). Same bytes that would otherwise be
113    /// base58-encoded as the `?invitation=…` URL parameter.
114    pub invitation_payload: Vec<u8>,
115    /// Optional sender-typed message rendered above the Accept
116    /// button. `None` means "no extra text"; the recipient SHOULD
117    /// hide the message box entirely in that case rather than
118    /// rendering an empty line.
119    #[serde(default)]
120    pub personal_message: Option<String>,
121}
122
123/// Encode a [`DirectMessageBody`] to wire bytes per the format described
124/// in the module docs (magic byte + CBOR). The returned `Vec<u8>` is
125/// what gets passed to `compose_direct_message`'s `body` parameter.
126pub fn encode_body(body: &DirectMessageBody) -> Result<Vec<u8>, String> {
127    let mut out = Vec::with_capacity(64);
128    out.push(DM_BODY_MAGIC);
129    ciborium::ser::into_writer(body, &mut out)
130        .map_err(|e| format!("encode_body: CBOR serialization failed: {}", e))?;
131    Ok(out)
132}
133
134/// Decode wire bytes back into a [`DirectMessageBody`].
135///
136/// Decoding rules:
137///
138/// 1. If `bytes` is empty → returns [`DirectMessageBody::Text`] with an
139///    empty string. (Defensive — a zero-length plaintext shouldn't
140///    happen in practice but we'd rather surface it as an empty text
141///    bubble than an error.)
142/// 2. If `bytes[0] == DM_BODY_MAGIC` → strip the magic byte and CBOR-
143///    decode the rest. Failures bubble up as `Err(String)` so the
144///    caller can render a placeholder (matches the existing "unable to
145///    decrypt" placeholder UX for inbound DMs).
146/// 3. Otherwise → treat as legacy plaintext, lossy-UTF-8-convert the
147///    entire byte slice, and return [`DirectMessageBody::Text`]. This
148///    is the path that keeps DMs sent by pre-Invite clients still
149///    rendering as text after this change ships.
150pub fn decode_body(bytes: &[u8]) -> Result<DirectMessageBody, String> {
151    if bytes.is_empty() {
152        return Ok(DirectMessageBody::Text {
153            text: String::new(),
154        });
155    }
156    if bytes[0] == DM_BODY_MAGIC {
157        let body: DirectMessageBody = ciborium::de::from_reader(&bytes[1..]).map_err(|e| {
158            format!(
159                "decode_body: CBOR deserialization of new-format body failed: {}",
160                e
161            )
162        })?;
163        return Ok(body);
164    }
165    // Legacy path: pre-this-format DMs were raw UTF-8 bytes.
166    let text = String::from_utf8_lossy(bytes).into_owned();
167    Ok(DirectMessageBody::Text { text })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use ed25519_dalek::SigningKey;
174
175    fn sample_vk() -> VerifyingKey {
176        let seed = [7u8; 32];
177        SigningKey::from_bytes(&seed).verifying_key()
178    }
179
180    #[test]
181    fn encode_decode_text_round_trip() {
182        let body = DirectMessageBody::Text {
183            text: "Hello, peer".to_string(),
184        };
185        let bytes = encode_body(&body).expect("encode");
186        let decoded = decode_body(&bytes).expect("decode");
187        assert_eq!(body, decoded);
188    }
189
190    #[test]
191    fn encode_decode_text_empty_round_trip() {
192        let body = DirectMessageBody::Text {
193            text: String::new(),
194        };
195        let bytes = encode_body(&body).expect("encode");
196        let decoded = decode_body(&bytes).expect("decode");
197        assert_eq!(body, decoded);
198    }
199
200    #[test]
201    fn encode_decode_invite_round_trip() {
202        let body = DirectMessageBody::Invite(Box::new(InvitePayload {
203            room_owner_vk: sample_vk(),
204            invitation_payload: vec![1, 2, 3, 4, 5],
205            personal_message: Some("join us!".to_string()),
206        }));
207        let bytes = encode_body(&body).expect("encode");
208        let decoded = decode_body(&bytes).expect("decode");
209        assert_eq!(body, decoded);
210    }
211
212    #[test]
213    fn encode_decode_invite_round_trip_no_personal_message() {
214        let body = DirectMessageBody::Invite(Box::new(InvitePayload {
215            room_owner_vk: sample_vk(),
216            invitation_payload: vec![],
217            personal_message: None,
218        }));
219        let bytes = encode_body(&body).expect("encode");
220        let decoded = decode_body(&bytes).expect("decode");
221        assert_eq!(body, decoded);
222    }
223
224    #[test]
225    fn new_format_bytes_start_with_magic() {
226        let body = DirectMessageBody::Text {
227            text: "anything".to_string(),
228        };
229        let bytes = encode_body(&body).expect("encode");
230        assert_eq!(bytes[0], DM_BODY_MAGIC);
231    }
232
233    #[test]
234    fn legacy_text_decodes_as_text() {
235        // Pre-this-format DMs were raw UTF-8 bytes — exercise the
236        // fallback path that the deployed clients depend on.
237        let legacy_bytes = b"hello from the past";
238        let decoded = decode_body(legacy_bytes).expect("decode legacy");
239        assert_eq!(
240            decoded,
241            DirectMessageBody::Text {
242                text: "hello from the past".to_string()
243            }
244        );
245    }
246
247    #[test]
248    fn legacy_multibyte_utf8_decodes_as_text() {
249        // Make sure a legacy plaintext that starts with a normal UTF-8
250        // leading byte (here a 2-byte sequence) doesn't accidentally
251        // hit the new-format path.
252        let legacy_bytes = "café".as_bytes();
253        let decoded = decode_body(legacy_bytes).expect("decode legacy");
254        assert_eq!(
255            decoded,
256            DirectMessageBody::Text {
257                text: "café".to_string()
258            }
259        );
260    }
261
262    #[test]
263    fn empty_bytes_decode_as_empty_text() {
264        let decoded = decode_body(&[]).expect("decode empty");
265        assert_eq!(
266            decoded,
267            DirectMessageBody::Text {
268                text: String::new()
269            }
270        );
271    }
272
273    #[test]
274    fn malformed_new_format_returns_err() {
275        // Magic byte present but the rest is not valid CBOR for our
276        // enum. The caller (UI bubble / CLI list) renders a placeholder
277        // when this errors.
278        let bytes = vec![DM_BODY_MAGIC, 0xff, 0xff, 0xff];
279        assert!(decode_body(&bytes).is_err());
280    }
281
282    #[test]
283    fn encoding_is_deterministic_for_text() {
284        // CBOR can have multiple valid encodings for the same value
285        // (canonical vs. non-canonical maps, indefinite-length items,
286        // etc.). `ciborium` produces canonical CBOR by default — pin
287        // this property so a future ciborium-version bump that loosens
288        // it would be caught by tests.
289        let body = DirectMessageBody::Text {
290            text: "stable bytes please".to_string(),
291        };
292        let a = encode_body(&body).expect("first encode");
293        let b = encode_body(&body).expect("second encode");
294        assert_eq!(a, b, "encode_body must be deterministic");
295    }
296
297    #[test]
298    fn encoding_is_deterministic_for_invite() {
299        let body = DirectMessageBody::Invite(Box::new(InvitePayload {
300            room_owner_vk: sample_vk(),
301            invitation_payload: vec![0xde, 0xad, 0xbe, 0xef],
302            personal_message: Some("stable".to_string()),
303        }));
304        let a = encode_body(&body).expect("first encode");
305        let b = encode_body(&body).expect("second encode");
306        assert_eq!(a, b, "encode_body must be deterministic");
307    }
308
309    #[test]
310    fn invite_with_large_payload_round_trips() {
311        // Pin that large invitation payloads (close to the body cap)
312        // still round-trip cleanly. Use 16 KiB — half the body cap, well
313        // beyond typical `Invitation` encoded size (~200 bytes today)
314        // but bounded so the test stays cheap.
315        let payload = vec![0xAB; 16 * 1024];
316        let body = DirectMessageBody::Invite(Box::new(InvitePayload {
317            room_owner_vk: sample_vk(),
318            invitation_payload: payload,
319            personal_message: None,
320        }));
321        let bytes = encode_body(&body).expect("encode");
322        let decoded = decode_body(&bytes).expect("decode");
323        assert_eq!(body, decoded);
324    }
325
326    #[test]
327    fn legacy_text_with_invalid_utf8_decodes_lossily() {
328        // A pre-format DM that somehow contained invalid UTF-8 (the
329        // CLI's `dm send` only sends UTF-8 strings, but defensively
330        // handle anything). `String::from_utf8_lossy` replaces invalid
331        // sequences with U+FFFD.
332        let bytes: Vec<u8> = vec![b'h', b'i', 0xFF, b'!'];
333        let decoded = decode_body(&bytes).expect("decode");
334        match decoded {
335            DirectMessageBody::Text { text } => {
336                assert!(text.starts_with("hi"), "got: {:?}", text);
337                assert!(text.ends_with("!"), "got: {:?}", text);
338                assert!(text.contains('\u{FFFD}'), "expected replacement char");
339            }
340            other => panic!("expected Text, got {:?}", other),
341        }
342    }
343
344    #[test]
345    fn legacy_plain_text_never_collides_with_magic() {
346        // Sanity-pin the cornerstone invariant: no valid UTF-8 string
347        // begins with the magic continuation byte. We verify this by
348        // attempting to construct each valid 1..=4-byte UTF-8 leading
349        // byte form and asserting none of them equal DM_BODY_MAGIC.
350        //
351        // The point isn't to brute-force all valid UTF-8 leading
352        // bytes — that would be 0x00-0x7F (1-byte) + 0xC2-0xDF (2-byte
353        // lead) + 0xE0-0xEF (3-byte lead) + 0xF0-0xF4 (4-byte lead) —
354        // it's to fail loudly if someone "fixes" DM_BODY_MAGIC to
355        // some value that IS a valid UTF-8 leading byte (e.g. 0x20,
356        // 0x7B), at which point a user typing a DM that begins with
357        // that character would silently mis-decode.
358        assert!(
359            DM_BODY_MAGIC >= 0x80 && DM_BODY_MAGIC <= 0xBF,
360            "DM_BODY_MAGIC must be a UTF-8 continuation byte (0x80..=0xBF) so it cannot start a valid UTF-8 string"
361        );
362    }
363}