use crate::{Result, SealedError};
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use serde::{Deserialize, Serialize};
const MAX_STRING_FIELD_LEN: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct GroupId(String);
impl GroupId {
pub const MAX_LEN: usize = MAX_STRING_FIELD_LEN;
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
Self::validate(&id)?;
Ok(Self(id))
}
fn validate(id: &str) -> Result<()> {
if id.is_empty() {
return Err(SealedError::InvalidGroupId(
"Group ID cannot be empty".to_string(),
));
}
if id.len() > Self::MAX_LEN {
return Err(SealedError::InvalidGroupId(format!(
"Group ID length {} exceeds maximum {}",
id.len(),
Self::MAX_LEN
)));
}
for segment in id.split(':') {
offline_protocol_core::validate_id_chars(segment, "Group ID segment")
.map_err(|e| SealedError::InvalidGroupId(e.to_string()))?;
}
Ok(())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn for_session(user_a: &str, user_b: &str) -> Result<Self> {
use offline_protocol_core::Address;
let ordered = match (user_a.parse::<Address>(), user_b.parse::<Address>()) {
(Ok(addr_a), Ok(addr_b)) if addr_b < addr_a => [user_b, user_a],
(Ok(_), Ok(_)) => [user_a, user_b],
_ => {
let mut users = [user_a, user_b];
users.sort();
users
}
};
Self::new(format!("session:{}:{}", ordered[0], ordered[1]))
}
}
impl fmt::Display for GroupId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl<'de> Deserialize<'de> for GroupId {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> core::result::Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
GroupId::new(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MlsMessageType {
Application,
Welcome,
Commit,
Proposal,
}
impl MlsMessageType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Application => "Application",
Self::Welcome => "Welcome",
Self::Commit => "Commit",
Self::Proposal => "Proposal",
}
}
pub fn from_str_opt(s: &str) -> Option<Self> {
match s {
"Application" => Some(Self::Application),
"Welcome" => Some(Self::Welcome),
"Commit" => Some(Self::Commit),
"Proposal" => Some(Self::Proposal),
_ => None,
}
}
pub fn as_u8(&self) -> u8 {
match self {
Self::Application => 0,
Self::Welcome => 1,
Self::Commit => 2,
Self::Proposal => 3,
}
}
pub fn from_u8(tag: u8) -> Option<Self> {
match tag {
0 => Some(Self::Application),
1 => Some(Self::Welcome),
2 => Some(Self::Commit),
3 => Some(Self::Proposal),
_ => None,
}
}
}
impl fmt::Display for MlsMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedMessage {
pub group_id: GroupId,
pub message_type: MlsMessageType,
pub epoch: u64,
pub ciphertext: Vec<u8>,
pub sender_id: String,
pub timestamp_ms: u64,
}
impl EncryptedMessage {
pub fn to_base64(&self) -> Result<String> {
let json =
serde_json::to_vec(self).map_err(|e| SealedError::Serialization(e.to_string()))?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&json,
))
}
pub fn from_base64(encoded: &str) -> Result<Self> {
let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
.map_err(|e| SealedError::Deserialization(e.to_string()))?;
serde_json::from_slice(&json).map_err(|e| SealedError::Deserialization(e.to_string()))
}
const MAX_CIPHERTEXT_LEN: usize = 128 * 1024 * 1024;
pub fn to_bytes(&self) -> Vec<u8> {
let group_id = self.group_id.as_str().as_bytes();
let sender_id = self.sender_id.as_bytes();
let capacity =
4 + group_id.len() + 4 + sender_id.len() + 1 + 8 + 8 + 4 + self.ciphertext.len();
let mut buf = Vec::with_capacity(capacity);
buf.extend_from_slice(&(group_id.len() as u32).to_le_bytes());
buf.extend_from_slice(group_id);
buf.extend_from_slice(&(sender_id.len() as u32).to_le_bytes());
buf.extend_from_slice(sender_id);
buf.push(self.message_type.as_u8());
buf.extend_from_slice(&self.epoch.to_le_bytes());
buf.extend_from_slice(&self.timestamp_ms.to_le_bytes());
buf.extend_from_slice(&(self.ciphertext.len() as u32).to_le_bytes());
buf.extend_from_slice(&self.ciphertext);
buf
}
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let err = SealedError::Deserialization;
let mut pos = 0;
let read_u32 = |pos: &mut usize| -> Result<u32> {
if *pos + 4 > data.len() {
return Err(err("unexpected end of data reading u32".to_string()));
}
let mut bytes = [0u8; 4];
bytes.copy_from_slice(&data[*pos..*pos + 4]);
let val = u32::from_le_bytes(bytes);
*pos += 4;
Ok(val)
};
let read_u64 = |pos: &mut usize| -> Result<u64> {
if *pos + 8 > data.len() {
return Err(err("unexpected end of data reading u64".to_string()));
}
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&data[*pos..*pos + 8]);
let val = u64::from_le_bytes(bytes);
*pos += 8;
Ok(val)
};
let read_bytes = |pos: &mut usize, len: usize| -> Result<Vec<u8>> {
if *pos + len > data.len() {
return Err(err("unexpected end of data reading bytes".to_string()));
}
let val = data[*pos..*pos + len].to_vec();
*pos += len;
Ok(val)
};
let group_id_len = read_u32(&mut pos)? as usize;
if group_id_len > MAX_STRING_FIELD_LEN {
return Err(err(format!(
"group_id_len {} exceeds maximum {}",
group_id_len, MAX_STRING_FIELD_LEN
)));
}
let group_id = String::from_utf8(read_bytes(&mut pos, group_id_len)?)
.map_err(|e| err(format!("invalid group_id UTF-8: {}", e)))?;
let sender_len = read_u32(&mut pos)? as usize;
if sender_len > MAX_STRING_FIELD_LEN {
return Err(err(format!(
"sender_len {} exceeds maximum {}",
sender_len, MAX_STRING_FIELD_LEN
)));
}
let sender_id = String::from_utf8(read_bytes(&mut pos, sender_len)?)
.map_err(|e| err(format!("invalid sender_id UTF-8: {}", e)))?;
if pos + 1 > data.len() {
return Err(err(
"unexpected end of data reading message_type".to_string()
));
}
let message_type = MlsMessageType::from_u8(data[pos])
.ok_or_else(|| err(format!("unknown message_type tag {}", data[pos])))?;
pos += 1;
let epoch = read_u64(&mut pos)?;
let timestamp_ms = read_u64(&mut pos)?;
let ciphertext_len = read_u32(&mut pos)? as usize;
if ciphertext_len > Self::MAX_CIPHERTEXT_LEN {
return Err(err(format!(
"ciphertext_len {} exceeds maximum {}",
ciphertext_len,
Self::MAX_CIPHERTEXT_LEN
)));
}
let ciphertext = read_bytes(&mut pos, ciphertext_len)?;
Ok(Self {
group_id: GroupId::new(group_id)?,
message_type,
epoch,
ciphertext,
sender_id,
timestamp_ms,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WelcomeMessage {
pub group_id: GroupId,
pub welcome_data: Vec<u8>,
pub inviter_id: String,
pub group_name: Option<String>,
pub timestamp_ms: u64,
}
impl WelcomeMessage {
pub fn to_base64(&self) -> Result<String> {
let json =
serde_json::to_vec(self).map_err(|e| SealedError::Serialization(e.to_string()))?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&json,
))
}
pub fn from_base64(encoded: &str) -> Result<Self> {
let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
.map_err(|e| SealedError::Deserialization(e.to_string()))?;
serde_json::from_slice(&json).map_err(|e| SealedError::Deserialization(e.to_string()))
}
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use offline_protocol_core::Address;
fn disagreeing_addresses() -> (Address, Address) {
let mut low_bytes = [0u8; Address::HASH_LEN];
low_bytes[0] = 0x00;
let mut high_bytes = [0u8; Address::HASH_LEN];
high_bytes[0] = 0x40;
(
Address::from_hash_bytes(low_bytes),
Address::from_hash_bytes(high_bytes),
)
}
#[test]
fn session_id_orders_addresses_by_hash_not_by_string() {
let (low, high) = disagreeing_addresses();
assert!(low < high, "hash-byte order");
assert!(
low.to_string() > high.to_string(),
"rendered-string order must be the opposite, or this test proves nothing"
);
let expected = format!("session:{}:{}", low, high);
assert_eq!(
GroupId::for_session(&low.to_string(), &high.to_string())
.unwrap()
.as_str(),
expected,
"address slots must follow Address order, not string order"
);
}
#[test]
fn session_id_is_symmetric_for_addresses() {
let (low, high) = disagreeing_addresses();
let (a, b) = (low.to_string(), high.to_string());
assert_eq!(
GroupId::for_session(&a, &b).unwrap(),
GroupId::for_session(&b, &a).unwrap(),
"both peers must derive the same slot regardless of argument order"
);
}
#[test]
fn session_id_falls_back_to_string_order_for_non_addresses() {
assert_eq!(
GroupId::for_session("bob", "alice").unwrap().as_str(),
"session:alice:bob"
);
let (address, _) = disagreeing_addresses();
let mixed = GroupId::for_session("alice", &address.to_string()).unwrap();
let mixed_reversed = GroupId::for_session(&address.to_string(), "alice").unwrap();
assert_eq!(mixed, mixed_reversed, "mixed pairs must stay symmetric");
}
fn sample_encrypted_message() -> EncryptedMessage {
EncryptedMessage {
group_id: GroupId::for_session("alice", "bob").unwrap(),
message_type: MlsMessageType::Application,
epoch: 42,
ciphertext: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01],
sender_id: "alice".to_string(),
timestamp_ms: 1_700_000_000_123,
}
}
#[test]
fn test_encrypted_message_bytes_roundtrip() {
let msg = sample_encrypted_message();
let bytes = msg.to_bytes();
let decoded = EncryptedMessage::from_bytes(&bytes).unwrap();
assert_eq!(decoded.group_id, msg.group_id);
assert_eq!(decoded.message_type, msg.message_type);
assert_eq!(decoded.epoch, msg.epoch);
assert_eq!(decoded.ciphertext, msg.ciphertext);
assert_eq!(decoded.sender_id, msg.sender_id);
assert_eq!(decoded.timestamp_ms, msg.timestamp_ms);
}
#[test]
fn test_encrypted_message_bytes_roundtrip_empty_ciphertext() {
let mut msg = sample_encrypted_message();
msg.ciphertext = Vec::new();
let decoded = EncryptedMessage::from_bytes(&msg.to_bytes()).unwrap();
assert!(decoded.ciphertext.is_empty());
}
#[test]
fn test_encrypted_message_from_bytes_truncated() {
let bytes = sample_encrypted_message().to_bytes();
for len in 0..bytes.len() {
assert!(
EncryptedMessage::from_bytes(&bytes[..len]).is_err(),
"truncation at {} unexpectedly succeeded",
len
);
}
}
#[test]
fn test_encrypted_message_from_bytes_oversized_group_id_rejected() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(u32::MAX).to_le_bytes());
let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
assert!(err.to_string().contains("group_id_len"));
}
#[test]
fn test_encrypted_message_from_bytes_oversized_sender_id_rejected() {
let mut bytes = Vec::new();
let group_id = b"session:alice:bob";
bytes.extend_from_slice(&(group_id.len() as u32).to_le_bytes());
bytes.extend_from_slice(group_id);
bytes.extend_from_slice(&(u32::MAX).to_le_bytes());
let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
assert!(
err.to_string().contains("sender_len"),
"expected the sender_len cap to refuse it, got: {err}"
);
}
#[test]
fn test_encrypted_message_from_bytes_oversized_ciphertext_rejected() {
let mut msg = sample_encrypted_message();
msg.ciphertext = vec![0u8; 4];
let mut bytes = msg.to_bytes();
let ct_len_offset = bytes.len() - 4 - 4;
bytes[ct_len_offset..ct_len_offset + 4].copy_from_slice(&(u32::MAX).to_le_bytes());
let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
assert!(err.to_string().contains("ciphertext_len"));
}
#[test]
fn test_encrypted_message_from_bytes_unknown_message_type_rejected() {
let msg = sample_encrypted_message();
let mut bytes = msg.to_bytes();
let tag_offset = 4 + msg.group_id.as_str().len() + 4 + msg.sender_id.len();
bytes[tag_offset] = 0xFF;
let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
assert!(err.to_string().contains("message_type"));
}
#[test]
fn json_bodies_are_refused_by_the_compact_parser() {
let json = serde_json::to_vec(&sample_encrypted_message()).unwrap();
assert!(json.starts_with(b"{\""), "premise: JSON starts with {{\"");
let err = EncryptedMessage::from_bytes(&json).unwrap_err();
assert!(
err.to_string().contains("group_id_len"),
"a JSON body must be refused by the string-field cap, got: {err}"
);
let claimed = u32::from_le_bytes([json[0], json[1], json[2], json[3]]) as usize;
assert!(
claimed > MAX_STRING_FIELD_LEN,
"the cap only disambiguates while it stays below {claimed}"
);
}
#[test]
fn test_group_id_accepts_legitimate_formats() {
assert!(GroupId::new("group:0bd6e5f2-3a70-4a4e-9c3f-1c1f2a3b4c5d").is_ok());
assert!(GroupId::new("session:alice:bob").is_ok());
assert!(GroupId::new("plain-segment_1.x@y").is_ok());
}
#[test]
fn test_group_id_rejects_storage_hostile_chars() {
assert!(GroupId::new("").is_err());
assert!(GroupId::new("group/evil").is_err());
assert!(GroupId::new("group\\evil").is_err());
assert!(GroupId::new("group\0evil").is_err());
assert!(GroupId::new("group\nevil").is_err());
assert!(GroupId::new("group\x7Fevil").is_err()); assert!(GroupId::new(".").is_err());
assert!(GroupId::new("..").is_err());
assert!(GroupId::new("session:..:bob").is_err());
assert!(GroupId::new("group:../../etc").is_err());
assert!(GroupId::new("session:").is_err());
assert!(GroupId::new(":session").is_err());
assert!(GroupId::new("a::b").is_err());
}
#[test]
fn test_group_id_length_cap() {
assert!(GroupId::new("a".repeat(GroupId::MAX_LEN)).is_ok());
assert!(GroupId::new("a".repeat(GroupId::MAX_LEN + 1)).is_err());
}
#[test]
fn test_group_id_deserialize_rejects_hostile() {
assert!(serde_json::from_str::<GroupId>(r#""evil/path""#).is_err());
assert!(serde_json::from_str::<GroupId>(r#""..""#).is_err());
assert!(serde_json::from_str::<GroupId>(r#""session:alice:bob""#).is_ok());
}
#[test]
fn test_encrypted_message_from_bytes_rejects_hostile_group_id() {
let mut bytes = Vec::new();
let hostile = b"evil/../path";
bytes.extend_from_slice(&(hostile.len() as u32).to_le_bytes());
bytes.extend_from_slice(hostile);
let sender = b"alice";
bytes.extend_from_slice(&(sender.len() as u32).to_le_bytes());
bytes.extend_from_slice(sender);
bytes.push(0); bytes.extend_from_slice(&42u64.to_le_bytes()); bytes.extend_from_slice(&1u64.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
assert!(matches!(err, SealedError::InvalidGroupId(_)));
}
#[test]
fn test_mls_message_type_u8_roundtrip() {
for msg_type in [
MlsMessageType::Application,
MlsMessageType::Welcome,
MlsMessageType::Commit,
MlsMessageType::Proposal,
] {
assert_eq!(MlsMessageType::from_u8(msg_type.as_u8()), Some(msg_type));
}
assert_eq!(MlsMessageType::from_u8(4), None);
}
}