//! Rich application-layer message format for MLS application payloads. //! //! The server sees only opaque ciphertext; structure lives in this client-defined //! plaintext schema. All messages use: version byte (1) + message_type byte + type-specific payload. //! //! # Message ID //! //! `message_id` is assigned by the sender (16 random bytes) and included in the //! serialized payload for Chat (and implied for Reply/Reaction/ReadReceipt via ref_msg_id). //! Recipients can store message_ids to reference them in replies or reactions. use crate::error::CoreError; use rand::RngCore; /// Current schema version. pub const VERSION: u8 = 1; /// Message type discriminant (one byte). #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum MessageType { Chat = 0x01, Reply = 0x02, Reaction = 0x03, ReadReceipt = 0x04, Typing = 0x05, Edit = 0x06, Delete = 0x07, FileRef = 0x08, Dummy = 0x09, } impl MessageType { fn from_byte(b: u8) -> Option { match b { 0x01 => Some(MessageType::Chat), 0x02 => Some(MessageType::Reply), 0x03 => Some(MessageType::Reaction), 0x04 => Some(MessageType::ReadReceipt), 0x05 => Some(MessageType::Typing), 0x06 => Some(MessageType::Edit), 0x07 => Some(MessageType::Delete), 0x08 => Some(MessageType::FileRef), 0x09 => Some(MessageType::Dummy), _ => None, } } } /// Parsed application message (one of the rich types). #[derive(Clone, Debug, PartialEq, Eq)] pub enum AppMessage { /// Plain chat: body (UTF-8). message_id is included so recipients can store and reference it. Chat { message_id: [u8; 16], body: Vec, }, Reply { ref_msg_id: [u8; 16], body: Vec, }, Reaction { ref_msg_id: [u8; 16], emoji: Vec, }, ReadReceipt { msg_id: [u8; 16], }, Typing { /// 0 = stopped, 1 = typing active: u8, }, /// Edit a previously sent message (identified by ref_msg_id). Edit { ref_msg_id: [u8; 16], body: Vec, }, /// Delete a previously sent message (identified by ref_msg_id). Delete { ref_msg_id: [u8; 16], }, /// File reference: metadata pointing to a blob stored on the server. FileRef { blob_id: [u8; 32], filename: Vec, file_size: u64, mime_type: Vec, }, /// Dummy message for traffic analysis resistance (no user-visible content). Dummy, } /// Generate a new 16-byte message ID (e.g. for Chat/Reply so recipients can reference it). pub fn generate_message_id() -> [u8; 16] { let mut id = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut id); id } // ── Layout (minimal, no Cap'n Proto) ───────────────────────────────────────── // // All messages: [version: 1][type: 1][payload...] // // Chat: [msg_id: 16][body_len: 2 BE][body] // Reply: [ref_msg_id: 16][body_len: 2 BE][body] // Reaction: [ref_msg_id: 16][emoji_len: 1][emoji] // ReadReceipt: [msg_id: 16] // Typing: [active: 1] 0 = stopped, 1 = typing // Edit: [ref_msg_id: 16][body_len: 2 BE][body] // Delete: [ref_msg_id: 16] // FileRef: [blob_id: 32][filename_len: 2 BE][filename][file_size: 8 BE][mime_len: 2 BE][mime_type] /// Serialize a rich message into the application payload format. pub fn serialize(msg_type: MessageType, payload: &[u8]) -> Vec { let mut out = Vec::with_capacity(2 + payload.len()); out.push(VERSION); out.push(msg_type as u8); out.extend_from_slice(payload); out } /// Serialize a Chat message (generates message_id internally; pass None to generate, or Some(id) when replying with a known id). pub fn serialize_chat(body: &[u8], message_id: Option<[u8; 16]>) -> Result, CoreError> { if body.len() > u16::MAX as usize { return Err(CoreError::AppMessage("chat body exceeds maximum length (65535 bytes)".into())); } let id = message_id.unwrap_or_else(generate_message_id); let mut payload = Vec::with_capacity(16 + 2 + body.len()); payload.extend_from_slice(&id); payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); payload.extend_from_slice(body); Ok(serialize(MessageType::Chat, &payload)) } /// Serialize a Reply message. pub fn serialize_reply(ref_msg_id: [u8; 16], body: &[u8]) -> Result, CoreError> { if body.len() > u16::MAX as usize { return Err(CoreError::AppMessage("reply body exceeds maximum length (65535 bytes)".into())); } let mut payload = Vec::with_capacity(16 + 2 + body.len()); payload.extend_from_slice(&ref_msg_id); payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); payload.extend_from_slice(body); Ok(serialize(MessageType::Reply, &payload)) } /// Serialize a Reaction message. pub fn serialize_reaction(ref_msg_id: [u8; 16], emoji: &[u8]) -> Result, CoreError> { if emoji.len() > 255 { return Err(CoreError::AppMessage("emoji length > 255".into())); } let mut payload = Vec::with_capacity(16 + 1 + emoji.len()); payload.extend_from_slice(&ref_msg_id); payload.push(emoji.len() as u8); payload.extend_from_slice(emoji); Ok(serialize(MessageType::Reaction, &payload)) } /// Serialize a ReadReceipt message. pub fn serialize_read_receipt(msg_id: [u8; 16]) -> Vec { serialize(MessageType::ReadReceipt, &msg_id) } /// Serialize a Typing message (active: 0 = stopped, 1 = typing). pub fn serialize_typing(active: u8) -> Vec { let payload = [active]; serialize(MessageType::Typing, &payload) } /// Serialize an Edit message (replaces body of a previously sent message). pub fn serialize_edit(ref_msg_id: &[u8; 16], body: &[u8]) -> Result, CoreError> { if body.len() > u16::MAX as usize { return Err(CoreError::AppMessage("edit body exceeds maximum length (65535 bytes)".into())); } let mut payload = Vec::with_capacity(16 + 2 + body.len()); payload.extend_from_slice(ref_msg_id); payload.extend_from_slice(&(body.len() as u16).to_be_bytes()); payload.extend_from_slice(body); Ok(serialize(MessageType::Edit, &payload)) } /// Serialize a Delete message (marks a previously sent message as deleted). pub fn serialize_delete(ref_msg_id: &[u8; 16]) -> Vec { serialize(MessageType::Delete, ref_msg_id) } /// Serialize a FileRef message (metadata pointing to a blob on the server). pub fn serialize_file_ref( blob_id: &[u8; 32], filename: &[u8], file_size: u64, mime_type: &[u8], ) -> Result, CoreError> { if filename.len() > u16::MAX as usize { return Err(CoreError::AppMessage("filename exceeds maximum length".into())); } if mime_type.len() > u16::MAX as usize { return Err(CoreError::AppMessage("mime_type exceeds maximum length".into())); } let mut payload = Vec::with_capacity(32 + 2 + filename.len() + 8 + 2 + mime_type.len()); payload.extend_from_slice(blob_id); payload.extend_from_slice(&(filename.len() as u16).to_be_bytes()); payload.extend_from_slice(filename); payload.extend_from_slice(&file_size.to_be_bytes()); payload.extend_from_slice(&(mime_type.len() as u16).to_be_bytes()); payload.extend_from_slice(mime_type); Ok(serialize(MessageType::FileRef, &payload)) } /// Serialize a Dummy message (traffic padding — no user content). pub fn serialize_dummy() -> Vec { serialize(MessageType::Dummy, &[]) } /// Parse bytes into (MessageType, AppMessage). Fails if version/type unknown or payload too short. pub fn parse(bytes: &[u8]) -> Result<(MessageType, AppMessage), CoreError> { if bytes.len() < 2 { return Err(CoreError::AppMessage("payload too short (need version + type)".into())); } let version = bytes[0]; if version != VERSION { return Err(CoreError::AppMessage(format!("unsupported version {version}"))); } let msg_type = MessageType::from_byte(bytes[1]) .ok_or_else(|| CoreError::AppMessage(format!("unknown message type {}", bytes[1])))?; let payload = &bytes[2..]; let app = match msg_type { MessageType::Chat => parse_chat(payload)?, MessageType::Reply => parse_reply(payload)?, MessageType::Reaction => parse_reaction(payload)?, MessageType::ReadReceipt => parse_read_receipt(payload)?, MessageType::Typing => parse_typing(payload)?, MessageType::Edit => parse_edit(payload)?, MessageType::Delete => parse_delete(payload)?, MessageType::FileRef => parse_file_ref(payload)?, MessageType::Dummy => AppMessage::Dummy, }; Ok((msg_type, app)) } fn parse_chat(payload: &[u8]) -> Result { if payload.len() < 16 + 2 { return Err(CoreError::AppMessage("Chat payload too short".into())); } let mut message_id = [0u8; 16]; message_id.copy_from_slice(&payload[..16]); let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; if payload.len() < 18 + body_len { return Err(CoreError::AppMessage("Chat body length exceeds payload".into())); } let body = payload[18..18 + body_len].to_vec(); Ok(AppMessage::Chat { message_id, body }) } fn parse_reply(payload: &[u8]) -> Result { if payload.len() < 16 + 2 { return Err(CoreError::AppMessage("Reply payload too short".into())); } let mut ref_msg_id = [0u8; 16]; ref_msg_id.copy_from_slice(&payload[..16]); let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; if payload.len() < 18 + body_len { return Err(CoreError::AppMessage("Reply body length exceeds payload".into())); } let body = payload[18..18 + body_len].to_vec(); Ok(AppMessage::Reply { ref_msg_id, body }) } fn parse_reaction(payload: &[u8]) -> Result { if payload.len() < 16 + 1 { return Err(CoreError::AppMessage("Reaction payload too short".into())); } let mut ref_msg_id = [0u8; 16]; ref_msg_id.copy_from_slice(&payload[..16]); let emoji_len = payload[16] as usize; if payload.len() < 17 + emoji_len { return Err(CoreError::AppMessage("Reaction emoji length exceeds payload".into())); } let emoji = payload[17..17 + emoji_len].to_vec(); Ok(AppMessage::Reaction { ref_msg_id, emoji }) } fn parse_read_receipt(payload: &[u8]) -> Result { if payload.len() < 16 { return Err(CoreError::AppMessage("ReadReceipt payload too short".into())); } let mut msg_id = [0u8; 16]; msg_id.copy_from_slice(&payload[..16]); Ok(AppMessage::ReadReceipt { msg_id }) } fn parse_typing(payload: &[u8]) -> Result { if payload.is_empty() { return Err(CoreError::AppMessage("Typing payload empty".into())); } Ok(AppMessage::Typing { active: payload[0] }) } fn parse_edit(payload: &[u8]) -> Result { if payload.len() < 16 + 2 { return Err(CoreError::AppMessage("Edit payload too short".into())); } let mut ref_msg_id = [0u8; 16]; ref_msg_id.copy_from_slice(&payload[..16]); let body_len = u16::from_be_bytes([payload[16], payload[17]]) as usize; if payload.len() < 18 + body_len { return Err(CoreError::AppMessage("Edit body length exceeds payload".into())); } let body = payload[18..18 + body_len].to_vec(); Ok(AppMessage::Edit { ref_msg_id, body }) } fn parse_delete(payload: &[u8]) -> Result { if payload.len() < 16 { return Err(CoreError::AppMessage("Delete payload too short".into())); } let mut ref_msg_id = [0u8; 16]; ref_msg_id.copy_from_slice(&payload[..16]); Ok(AppMessage::Delete { ref_msg_id }) } fn parse_file_ref(payload: &[u8]) -> Result { // blob_id(32) + filename_len(2) minimum if payload.len() < 34 { return Err(CoreError::AppMessage("FileRef payload too short".into())); } let mut blob_id = [0u8; 32]; blob_id.copy_from_slice(&payload[..32]); let filename_len = u16::from_be_bytes([payload[32], payload[33]]) as usize; let pos = 34; if payload.len() < pos + filename_len + 8 + 2 { return Err(CoreError::AppMessage("FileRef payload truncated after filename_len".into())); } let filename = payload[pos..pos + filename_len].to_vec(); let pos = pos + filename_len; let file_size = u64::from_be_bytes([ payload[pos], payload[pos + 1], payload[pos + 2], payload[pos + 3], payload[pos + 4], payload[pos + 5], payload[pos + 6], payload[pos + 7], ]); let pos = pos + 8; let mime_len = u16::from_be_bytes([payload[pos], payload[pos + 1]]) as usize; let pos = pos + 2; if payload.len() < pos + mime_len { return Err(CoreError::AppMessage("FileRef payload truncated after mime_len".into())); } let mime_type = payload[pos..pos + mime_len].to_vec(); Ok(AppMessage::FileRef { blob_id, filename, file_size, mime_type }) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn roundtrip_chat() { let body = b"hello"; let encoded = serialize_chat(body, None).unwrap(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Chat); match &msg { AppMessage::Chat { message_id: _, body: b } => assert_eq!(b.as_slice(), body), _ => panic!("expected Chat"), } } #[test] fn roundtrip_reply() { let ref_id = [1u8; 16]; let body = b"reply text"; let encoded = serialize_reply(ref_id, body).unwrap(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Reply); match &msg { AppMessage::Reply { ref_msg_id, body: b } => { assert_eq!(ref_msg_id, &ref_id); assert_eq!(b.as_slice(), body); } _ => panic!("expected Reply"), } } #[test] fn roundtrip_typing() { let encoded = serialize_typing(1); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Typing); match &msg { AppMessage::Typing { active } => assert_eq!(*active, 1), _ => panic!("expected Typing"), } } #[test] fn roundtrip_reaction() { let ref_id = [2u8; 16]; let emoji = "\u{1f44d}".as_bytes(); let encoded = serialize_reaction(ref_id, emoji).unwrap(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Reaction); match &msg { AppMessage::Reaction { ref_msg_id, emoji: e } => { assert_eq!(ref_msg_id, &ref_id); assert_eq!(e.as_slice(), emoji); } _ => panic!("expected Reaction"), } } #[test] fn roundtrip_read_receipt() { let msg_id = [3u8; 16]; let encoded = serialize_read_receipt(msg_id); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::ReadReceipt); match &msg { AppMessage::ReadReceipt { msg_id: id } => assert_eq!(id, &msg_id), _ => panic!("expected ReadReceipt"), } } #[test] fn roundtrip_edit() { let ref_id = [4u8; 16]; let body = b"edited text"; let encoded = serialize_edit(&ref_id, body).unwrap(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Edit); match &msg { AppMessage::Edit { ref_msg_id, body: b } => { assert_eq!(ref_msg_id, &ref_id); assert_eq!(b.as_slice(), body); } _ => panic!("expected Edit"), } } #[test] fn roundtrip_delete() { let ref_id = [5u8; 16]; let encoded = serialize_delete(&ref_id); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Delete); match &msg { AppMessage::Delete { ref_msg_id } => assert_eq!(ref_msg_id, &ref_id), _ => panic!("expected Delete"), } } #[test] fn edit_body_too_long() { let body = vec![0u8; 65536]; assert!(serialize_edit(&[0; 16], &body).is_err()); } #[test] fn parse_empty_fails() { assert!(parse(&[]).is_err()); } #[test] fn parse_bad_version_fails() { assert!(parse(&[99, 0x01]).is_err()); } #[test] fn parse_bad_type_fails() { assert!(parse(&[1, 0xFF]).is_err()); } #[test] fn chat_body_too_long() { let body = vec![0u8; 65536]; // exceeds u16::MAX assert!(serialize_chat(&body, None).is_err()); } #[test] fn reaction_emoji_too_long() { let emoji = vec![0u8; 256]; assert!(serialize_reaction([0; 16], &emoji).is_err()); } #[test] fn parse_truncated_chat_payload() { // Version + type + only 10 bytes of payload (needs 18 minimum for chat) let mut data = vec![1, 0x01]; data.extend_from_slice(&[0u8; 10]); assert!(parse(&data).is_err()); } #[test] fn roundtrip_file_ref() { let blob_id = [7u8; 32]; let filename = b"report.pdf"; let file_size = 123456u64; let mime_type = b"application/pdf"; let encoded = serialize_file_ref(&blob_id, filename, file_size, mime_type).unwrap(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::FileRef); match &msg { AppMessage::FileRef { blob_id: bid, filename: fname, file_size: fsize, mime_type: mtype, } => { assert_eq!(bid, &blob_id); assert_eq!(fname.as_slice(), filename); assert_eq!(*fsize, file_size); assert_eq!(mtype.as_slice(), mime_type); } _ => panic!("expected FileRef"), } } #[test] fn roundtrip_dummy() { let encoded = serialize_dummy(); let (t, msg) = parse(&encoded).unwrap(); assert_eq!(t, MessageType::Dummy); assert_eq!(msg, AppMessage::Dummy); } }