use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::files::encryption::EncryptedFileMeta;
use crate::storage::repo::RoomKind;
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,
}
#[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>,
},
SessionKeyRequest {
requester_fingerprint: String,
},
Encrypted {
sender_fingerprint: String,
session_id: String,
ciphertext_b64: String,
},
Plain {
sender_fingerprint: String,
body: String,
},
MemberLeave {
sender_fingerprint: String,
},
RotateRoomKey {
rotator_fingerprint: String,
new_salt: Vec<u8>,
},
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>,
},
}
#[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()),
},
RoomMessage::Plain {
sender_fingerprint: "fp".into(),
body: "hi".into(),
},
RoomMessage::Encrypted {
sender_fingerprint: "fp".into(),
session_id: "sid".into(),
ciphertext_b64: "ct".into(),
},
RoomMessage::SessionKeyRequest {
requester_fingerprint: "fp".into(),
},
RoomMessage::MemberLeave {
sender_fingerprint: "fp".into(),
},
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],
},
RoomMessage::Typing {
sender_fingerprint: "fp".into(),
},
];
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 room_topic_format() {
assert_eq!(room_topic("abc123"), "huddle-room-abc123");
}
}