use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoomKind {
Direct,
#[default]
Group,
}
impl RoomKind {
pub fn as_str(&self) -> &'static str {
match self {
RoomKind::Direct => "direct",
RoomKind::Group => "group",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"direct" => RoomKind::Direct,
_ => RoomKind::Group,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EncryptedFileMeta {
pub megolm_session_id: String,
pub wrapped_key_b64: String,
pub nonce_b64: String,
pub content_hash: String,
}
pub const ROOMS_TOPIC: &str = "huddle-rooms-v1";
pub const ROOM_TOPIC_PREFIX: &str = "huddle-room-";
pub fn room_topic(room_id: &str) -> String {
format!("{ROOM_TOPIC_PREFIX}{room_id}")
}
pub fn inbox_room_id(fingerprint: &str) -> String {
let mut h = Sha256::new();
h.update(b"huddle-inbox-v1");
h.update(fingerprint.as_bytes());
format!("inbox:{}", hex::encode(h.finalize()))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedRoomMessage {
pub fingerprint: String,
pub ed25519_pubkey_b64: String,
pub payload_b64: String,
pub signature_b64: String,
#[serde(default)]
pub signed_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mldsa_pubkey_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mldsa_signature_b64: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum WireMessage {
Plain(RoomMessage),
Signed(SignedRoomMessage),
}
pub fn encode_wire(msg: &RoomMessage) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec(&WireMessage::Plain(msg.clone()))
}
pub fn encode_wire_signed(env: &SignedRoomMessage) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec(&WireMessage::Signed(env.clone()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoomAnnouncement {
pub room_id: String,
pub name: String,
pub encrypted: bool,
pub passphrase_salt: Option<Vec<u8>>,
pub member_count: u32,
pub creator_fingerprint: String,
pub announced_at: i64,
#[serde(default)]
pub owner_fingerprints: Vec<String>,
#[serde(default)]
pub verified_only: bool,
#[serde(default)]
pub host_addrs: Vec<String>,
#[serde(default)]
pub kind: RoomKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoomMessage {
MemberAnnounce {
sender_fingerprint: String,
wrapped_session_key: Option<String>,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
sender_ed25519_pubkey: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sender_mlkem_pubkey: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mlkem_ciphertext: Option<String>,
},
SessionKeyRequest { requester_fingerprint: String },
Encrypted {
sender_fingerprint: String,
session_id: String,
ciphertext_b64: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_msg_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
},
Plain {
sender_fingerprint: String,
body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_msg_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
},
MemberLeave {
sender_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
room_id: Option<String>,
},
RotateRoomKey {
rotator_fingerprint: String,
new_salt: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
room_id: Option<String>,
},
Typing { sender_fingerprint: String },
FileOffer {
sender_fingerprint: String,
file_id: String,
name: String,
size_bytes: u64,
mime: Option<String>,
chunk_count: u32,
encrypted_meta: Option<EncryptedFileMeta>,
},
FileChunk {
sender_fingerprint: String,
file_id: String,
chunk_index: u32,
total_chunks: u32,
data_b64: String,
},
OwnerGrant {
room_id: String,
target_fingerprint: String,
},
BanMember {
room_id: String,
target_fingerprint: String,
},
SasInit {
tx_id: String,
ephemeral_x25519_pubkey_b64: String,
target_fingerprint: String,
},
SasResponse {
tx_id: String,
ephemeral_x25519_pubkey_b64: String,
},
SasConfirm { tx_id: String, matched: bool },
JoinRefused {
room_id: String,
target_fingerprint: String,
reason: String,
},
CodeJoinRequest {
room_id: String,
joiner_x25519_pubkey_b64: String,
code: String,
},
CodeJoinResponse {
room_id: String,
target_fingerprint: String,
owner_x25519_pubkey_b64: String,
owner_session_id: String,
wrapped_session_key_b64: String,
nonce_b64: String,
},
ProfileUpdate {
sender_fingerprint: String,
username: Option<String>,
updated_at: i64,
},
ContactRequest {
requester_fingerprint: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
note: Option<String>,
#[serde(default)]
sender_ed25519_pubkey: Option<String>,
},
Reaction {
sender_fingerprint: String,
target_msg_id: String,
emoji: String,
#[serde(default)]
removed: bool,
},
Edit {
sender_fingerprint: String,
target_msg_id: String,
new_ciphertext_b64: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
new_body: Option<String>,
},
Delete {
sender_fingerprint: String,
target_msg_id: String,
},
RoomSetting {
sender_fingerprint: String,
disappearing_ttl_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
room_id: Option<String>,
},
MlsKeyPackage {
sender_fingerprint: String,
key_package_b64: String,
},
MlsWelcome {
target_fingerprint: String,
welcome_b64: String,
},
MlsCommit {
sender_fingerprint: String,
commit_b64: String,
},
MlsApplication {
sender_fingerprint: String,
ciphertext_b64: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_announcement_round_trip() {
let ann = RoomAnnouncement {
room_id: "rid".into(),
name: "general".into(),
encrypted: true,
passphrase_salt: Some(vec![1, 2, 3, 4]),
member_count: 3,
creator_fingerprint: "creator-fp".into(),
announced_at: 100,
owner_fingerprints: vec!["creator-fp".into()],
verified_only: false,
host_addrs: vec![],
kind: RoomKind::Group,
};
let json = serde_json::to_vec(&ann).unwrap();
let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
assert_eq!(back.name, "general");
assert_eq!(back.passphrase_salt, Some(vec![1, 2, 3, 4]));
assert_eq!(back.kind, RoomKind::Group);
}
#[test]
fn room_announcement_direct_kind_round_trip() {
let ann = RoomAnnouncement {
room_id: "dm-rid".into(),
name: "dm".into(),
encrypted: false,
passphrase_salt: None,
member_count: 2,
creator_fingerprint: "alice-fp".into(),
announced_at: 100,
owner_fingerprints: vec![],
verified_only: false,
host_addrs: vec![],
kind: RoomKind::Direct,
};
let json = serde_json::to_vec(&ann).unwrap();
let back: RoomAnnouncement = serde_json::from_slice(&json).unwrap();
assert_eq!(back.kind, RoomKind::Direct);
}
#[test]
fn room_announcement_missing_kind_defaults_to_group() {
let pre_0_7_json = serde_json::json!({
"room_id": "rid",
"name": "general",
"encrypted": false,
"passphrase_salt": null,
"member_count": 1,
"creator_fingerprint": "creator-fp",
"announced_at": 100,
});
let back: RoomAnnouncement = serde_json::from_value(pre_0_7_json).unwrap();
assert_eq!(back.kind, RoomKind::Group);
}
#[test]
fn room_message_variants_round_trip() {
let msgs = vec![
RoomMessage::MemberAnnounce {
sender_fingerprint: "fp".into(),
wrapped_session_key: Some("base64data".into()),
display_name: Some("Daisy".into()),
sender_ed25519_pubkey: Some("AAA=".into()),
sender_mlkem_pubkey: Some("BBB=".into()),
mlkem_ciphertext: Some("CCC=".into()),
},
RoomMessage::Plain {
sender_fingerprint: "fp".into(),
body: "hi".into(),
client_msg_id: Some("cmid-1".into()),
reply_to: None,
},
RoomMessage::Encrypted {
sender_fingerprint: "fp".into(),
session_id: "sid".into(),
ciphertext_b64: "ct".into(),
client_msg_id: Some("cmid-2".into()),
reply_to: Some("cmid-1".into()),
},
RoomMessage::SessionKeyRequest {
requester_fingerprint: "fp".into(),
},
RoomMessage::MemberLeave {
sender_fingerprint: "fp".into(),
room_id: None,
},
RoomMessage::FileOffer {
sender_fingerprint: "fp".into(),
file_id: "fid".into(),
name: "f.bin".into(),
size_bytes: 1024,
mime: Some("application/octet-stream".into()),
chunk_count: 2,
encrypted_meta: None,
},
RoomMessage::FileChunk {
sender_fingerprint: "fp".into(),
file_id: "fid".into(),
chunk_index: 0,
total_chunks: 2,
data_b64: "AAA=".into(),
},
RoomMessage::RotateRoomKey {
rotator_fingerprint: "fp".into(),
new_salt: vec![1u8; 16],
room_id: None,
},
RoomMessage::Typing {
sender_fingerprint: "fp".into(),
},
RoomMessage::Reaction {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
emoji: "👍".into(),
removed: false,
},
RoomMessage::Edit {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
new_ciphertext_b64: "ct2".into(),
session_id: "sid".into(),
new_body: None,
},
RoomMessage::Edit {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
new_ciphertext_b64: String::new(),
session_id: String::new(),
new_body: Some("edited body".into()),
},
RoomMessage::Delete {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
},
RoomMessage::RoomSetting {
sender_fingerprint: "fp".into(),
disappearing_ttl_secs: 3600,
room_id: None,
},
];
for m in msgs {
let json = serde_json::to_vec(&m).unwrap();
let back: RoomMessage = serde_json::from_slice(&json).unwrap();
assert_eq!(format!("{m:?}"), format!("{back:?}"));
}
}
#[test]
fn plain_without_new_fields_defaults_to_none() {
let pre_2_0_json = serde_json::json!({
"Plain": {
"sender_fingerprint": "fp",
"body": "hi",
}
});
let back: RoomMessage = serde_json::from_value(pre_2_0_json).unwrap();
match back {
RoomMessage::Plain {
client_msg_id,
reply_to,
..
} => {
assert_eq!(client_msg_id, None);
assert_eq!(reply_to, None);
}
other => panic!("expected Plain, got {other:?}"),
}
}
#[test]
fn plain_with_none_ids_omits_fields_on_wire() {
let m = RoomMessage::Plain {
sender_fingerprint: "fp".into(),
body: "hi".into(),
client_msg_id: None,
reply_to: None,
};
let v = serde_json::to_value(&m).unwrap();
let inner = &v["Plain"];
assert!(inner.get("client_msg_id").is_none());
assert!(inner.get("reply_to").is_none());
}
#[test]
fn reaction_missing_removed_defaults_to_false() {
let json = serde_json::json!({
"Reaction": {
"sender_fingerprint": "fp",
"target_msg_id": "cmid-1",
"emoji": "❤️",
}
});
let back: RoomMessage = serde_json::from_value(json).unwrap();
match back {
RoomMessage::Reaction { removed, emoji, .. } => {
assert!(!removed);
assert_eq!(emoji, "❤️");
}
other => panic!("expected Reaction, got {other:?}"),
}
}
#[test]
fn edit_missing_session_id_defaults_to_empty() {
let json = serde_json::json!({
"Edit": {
"sender_fingerprint": "fp",
"target_msg_id": "cmid-1",
"new_ciphertext_b64": "ct2",
}
});
let back: RoomMessage = serde_json::from_value(json).unwrap();
match back {
RoomMessage::Edit { session_id, .. } => assert_eq!(session_id, ""),
other => panic!("expected Edit, got {other:?}"),
}
}
#[test]
fn edit_empty_session_id_omitted_on_wire() {
let plain = RoomMessage::Edit {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
new_ciphertext_b64: String::new(),
session_id: String::new(),
new_body: Some("edited".into()),
};
let v = serde_json::to_value(&plain).unwrap();
assert!(v["Edit"].get("session_id").is_none());
let enc = RoomMessage::Edit {
sender_fingerprint: "fp".into(),
target_msg_id: "cmid-1".into(),
new_ciphertext_b64: "ct2".into(),
session_id: "sid".into(),
new_body: None,
};
let v = serde_json::to_value(&enc).unwrap();
assert_eq!(v["Edit"]["session_id"], "sid");
}
#[test]
fn room_topic_format() {
assert_eq!(room_topic("abc123"), "huddle-room-abc123");
}
}