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}