1use blake2::{digest::consts::U32, Blake2b, Digest};
2use chacha20poly1305::{
3 aead::{Aead, Payload},
4 Key, KeyInit, XChaCha20Poly1305, XNonce,
5};
6use rand::RngCore;
7use serde::{Deserialize, Serialize};
8
9use crate::contact::Contact;
10use crate::error::{CoreError, Result};
11use crate::identity::ChatIdentity;
12use crate::ids::{msg_id_bytes, normalize_pubkey_hex, now_ms, random_hex};
13
14pub const GROUP_ID_BYTES: usize = 16;
15pub const GROUP_KEY_BYTES: usize = 32;
16pub const MAX_GROUP_MEMBERS: usize = 16;
17pub const MAX_GROUP_NAME_CHARS: usize = 80;
18pub const GROUP_MESSAGE_DOMAIN: &[u8] = b"nysiris-chat-v1/group-message";
19pub const GROUP_INVITE_DOMAIN: &[u8] = b"nysiris-chat-v1/group-invite";
20pub const GROUP_ENC_DOMAIN: &[u8] = b"nysiris-chat-v1/group-enc";
21pub const GROUP_INNER_VERSION: u8 = 1;
22
23type Blake2b256 = Blake2b<U32>;
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GroupMember {
27 pub petname: String,
28 pub pubkey: String,
29 pub nym_address: String,
30}
31
32impl GroupMember {
33 pub fn new(petname: &str, pubkey: &str, nym_address: &str) -> Result<Self> {
34 let petname = petname.trim();
35 if petname.is_empty() || petname.chars().count() > 64 {
36 return Err(CoreError::InvalidInput(
37 "member petname must be 1..=64 characters".into(),
38 ));
39 }
40 let nym_address = nym_address.trim();
41 if nym_address.is_empty() || nym_address.chars().count() > 256 {
42 return Err(CoreError::InvalidInput(
43 "member Nym address is invalid".into(),
44 ));
45 }
46 Ok(Self {
47 petname: petname.to_string(),
48 pubkey: normalize_pubkey_hex(pubkey)?,
49 nym_address: nym_address.to_string(),
50 })
51 }
52
53 pub fn from_contact(contact: &Contact) -> Result<Self> {
54 Self::new(&contact.petname, &contact.pubkey, &contact.nym_address)
55 }
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Group {
60 pub id: String,
61 pub name: String,
62 pub key: String,
63 pub members: Vec<GroupMember>,
64 pub created_at: u64,
65}
66
67impl Group {
68 pub fn create(name: &str, mut members: Vec<GroupMember>, own: GroupMember) -> Result<Self> {
70 let name = name.trim();
71 if name.is_empty() || name.chars().count() > MAX_GROUP_NAME_CHARS {
72 return Err(CoreError::InvalidInput(
73 "room name must be 1..=80 characters".into(),
74 ));
75 }
76 members.retain(|m| m.pubkey != own.pubkey);
77 members.push(own);
78 dedupe_members(&mut members)?;
79 if members.len() < 2 {
80 return Err(CoreError::InvalidInput(
81 "a room needs at least one peer besides you".into(),
82 ));
83 }
84 if members.len() > MAX_GROUP_MEMBERS {
85 return Err(CoreError::InvalidInput(format!(
86 "a room is limited to {MAX_GROUP_MEMBERS} members"
87 )));
88 }
89 let mut key_bytes = [0u8; GROUP_KEY_BYTES];
90 rand::rngs::OsRng.fill_bytes(&mut key_bytes);
91 Ok(Self {
92 id: random_hex(GROUP_ID_BYTES),
93 name: name.to_string(),
94 key: hex::encode(key_bytes),
95 members,
96 created_at: now_ms(),
97 })
98 }
99
100 pub fn conversation_id(&self) -> String {
101 format!("group:{}", self.id)
102 }
103
104 pub fn member(&self, pubkey: &str) -> Option<&GroupMember> {
105 let key = pubkey.trim().to_lowercase();
106 self.members.iter().find(|m| m.pubkey == key)
107 }
108
109 pub fn validate(&self) -> Result<()> {
110 let id = hex::decode(&self.id)
111 .map_err(|_| CoreError::InvalidInput("room id is not hex".into()))?;
112 if id.len() != GROUP_ID_BYTES {
113 return Err(CoreError::InvalidInput("room id must be 16 bytes".into()));
114 }
115 let key = hex::decode(&self.key)
116 .map_err(|_| CoreError::InvalidInput("room key is not hex".into()))?;
117 if key.len() != GROUP_KEY_BYTES {
118 return Err(CoreError::InvalidInput("room key must be 32 bytes".into()));
119 }
120 if self.name.trim().is_empty() || self.name.chars().count() > MAX_GROUP_NAME_CHARS {
121 return Err(CoreError::InvalidInput("room name is invalid".into()));
122 }
123 if self.members.len() < 2 || self.members.len() > MAX_GROUP_MEMBERS {
124 return Err(CoreError::InvalidInput(
125 "room member count is invalid".into(),
126 ));
127 }
128 let mut seen = std::collections::HashSet::new();
129 for member in &self.members {
130 GroupMember::new(&member.petname, &member.pubkey, &member.nym_address)?;
131 if !seen.insert(member.pubkey.clone()) {
132 return Err(CoreError::InvalidInput("duplicate room member".into()));
133 }
134 }
135 Ok(())
136 }
137
138 pub fn invite(&self, inviter: &ChatIdentity) -> Result<String> {
139 self.validate()?;
140 let inviter_pub = inviter.public_hex();
141 let inviter_member = self
142 .member(&inviter_pub)
143 .cloned()
144 .ok_or_else(|| CoreError::InvalidInput("inviter is not a room member".into()))?;
145 let mut members = sorted_members(&self.members);
146 members.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
148 let unsigned = GroupInviteUnsigned {
149 v: GROUP_INNER_VERSION,
150 group_id: self.id.clone(),
151 name: self.name.clone(),
152 key: self.key.clone(),
153 members,
154 inviter: inviter_member,
155 created_at: now_ms(),
156 };
157 let bytes = invite_sign_bytes(&unsigned)?;
158 let invite = GroupInvite {
159 unsigned,
160 sig: inviter.sign_hex(&bytes),
161 };
162 Ok(serde_json::to_string(&invite)?)
163 }
164
165 pub fn from_invite(json: &str, own_pubkey: &str) -> Result<Self> {
166 let invite: GroupInvite = serde_json::from_str(json)?;
167 invite.verify()?;
168 let own = normalize_pubkey_hex(own_pubkey)?;
169 if !invite.unsigned.members.iter().any(|m| m.pubkey == own) {
170 return Err(CoreError::InvalidInput(
171 "this identity is not a member of the invited room".into(),
172 ));
173 }
174 if invite.unsigned.members.len() < 2 || invite.unsigned.members.len() > MAX_GROUP_MEMBERS {
175 return Err(CoreError::InvalidInput(
176 "invite member count is invalid".into(),
177 ));
178 }
179 let group = Self {
180 id: invite.unsigned.group_id,
181 name: invite.unsigned.name,
182 key: invite.unsigned.key,
183 members: invite.unsigned.members,
184 created_at: invite.unsigned.created_at,
185 };
186 group.validate()?;
187 Ok(group)
188 }
189}
190
191fn dedupe_members(members: &mut Vec<GroupMember>) -> Result<()> {
192 let mut seen = std::collections::HashSet::new();
193 members.retain(|m| seen.insert(m.pubkey.clone()));
194 for member in members {
195 normalize_pubkey_hex(&member.pubkey)?;
196 }
197 Ok(())
198}
199
200fn sorted_members(members: &[GroupMember]) -> Vec<GroupMember> {
201 let mut out = members.to_vec();
202 out.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
203 out
204}
205
206#[derive(Clone, Debug, Serialize, Deserialize)]
207struct GroupInviteUnsigned {
208 v: u8,
209 group_id: String,
210 name: String,
211 key: String,
212 members: Vec<GroupMember>,
213 inviter: GroupMember,
214 created_at: u64,
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize)]
218struct GroupInvite {
219 #[serde(flatten)]
220 unsigned: GroupInviteUnsigned,
221 sig: String,
222}
223
224impl GroupInvite {
225 fn verify(&self) -> Result<()> {
226 if self.unsigned.v != GROUP_INNER_VERSION {
227 return Err(CoreError::InvalidInput(
228 "unsupported room invite version".into(),
229 ));
230 }
231 let bytes = invite_sign_bytes(&self.unsigned)?;
232 ChatIdentity::verify_hex(&self.unsigned.inviter.pubkey, &bytes, &self.sig)?;
233 if !self
234 .unsigned
235 .members
236 .iter()
237 .any(|m| m.pubkey == self.unsigned.inviter.pubkey)
238 {
239 return Err(CoreError::InvalidInput(
240 "invite signer is not in the member list".into(),
241 ));
242 }
243 Ok(())
244 }
245}
246
247fn invite_sign_bytes(unsigned: &GroupInviteUnsigned) -> Result<Vec<u8>> {
248 let body = serde_json::to_vec(unsigned)?;
249 let mut out = Vec::with_capacity(GROUP_INVITE_DOMAIN.len() + body.len());
250 out.extend_from_slice(GROUP_INVITE_DOMAIN);
251 out.extend_from_slice(&body);
252 Ok(out)
253}
254
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct GroupInner {
257 pub v: u8,
258 pub group_id: String,
259 pub from: String,
260 pub ts: u64,
261 #[serde(rename = "msgId")]
262 pub msg_id: String,
263 pub body: String,
264 pub sig: String,
265}
266
267#[derive(Clone, Debug, Serialize, Deserialize)]
268pub struct SealedGroupMessage {
269 pub v: u8,
270 pub gid: String,
271 #[serde(rename = "msgId")]
272 pub msg_id: String,
273 pub nonce: String,
274 pub ciphertext: String,
275}
276
277fn group_key_bytes(key_hex: &str) -> Result<[u8; 32]> {
278 let raw = hex::decode(key_hex.trim())
279 .map_err(|_| CoreError::InvalidKey("room key is not hex".into()))?;
280 if raw.len() != 32 {
281 return Err(CoreError::InvalidKey("room key must be 32 bytes".into()));
282 }
283 let mut out = [0u8; 32];
284 out.copy_from_slice(&raw);
285 Ok(out)
286}
287
288fn group_enc_key(key_hex: &str) -> Result<[u8; 32]> {
289 let key = group_key_bytes(key_hex)?;
290 let mut hasher = Blake2b256::new();
291 hasher.update(GROUP_ENC_DOMAIN);
292 hasher.update(key);
293 Ok(hasher.finalize().into())
294}
295
296fn group_aad(gid_hex: &str, msg_id_hex: &str) -> Result<Vec<u8>> {
297 let gid = hex::decode(gid_hex.trim())
298 .map_err(|_| CoreError::InvalidInput("room id is not hex".into()))?;
299 if gid.len() != GROUP_ID_BYTES {
300 return Err(CoreError::InvalidInput("room id must be 16 bytes".into()));
301 }
302 let msg_id = msg_id_bytes(msg_id_hex)?;
303 let mut out = Vec::with_capacity(gid.len() + msg_id.len());
304 out.extend_from_slice(&gid);
305 out.extend_from_slice(&msg_id);
306 Ok(out)
307}
308
309pub fn pack_group_inner(
310 identity: &ChatIdentity,
311 gid: &str,
312 msg_id: &str,
313 ts: u64,
314 body: &str,
315) -> Result<String> {
316 if body.is_empty() || body.len() > crate::message::MAX_BODY_BYTES {
317 return Err(CoreError::InvalidInput(
318 "room message body has an invalid size".into(),
319 ));
320 }
321 let gid_bytes = hex::decode(gid.trim())
322 .map_err(|_| CoreError::InvalidInput("room id is not hex".into()))?;
323 if gid_bytes.len() != GROUP_ID_BYTES {
324 return Err(CoreError::InvalidInput("room id must be 16 bytes".into()));
325 }
326 let msg_id_bytes = msg_id_bytes(msg_id)?;
327 let from = identity.public_hex();
328 let mut bytes = Vec::with_capacity(GROUP_MESSAGE_DOMAIN.len() + 16 + 32 + 8 + 16 + body.len());
329 bytes.extend_from_slice(GROUP_MESSAGE_DOMAIN);
330 bytes.extend_from_slice(&gid_bytes);
331 bytes.extend_from_slice(&crate::ids::pubkey_bytes(&from)?);
332 bytes.extend_from_slice(&ts.to_be_bytes());
333 bytes.extend_from_slice(&msg_id_bytes);
334 bytes.extend_from_slice(body.as_bytes());
335 let inner = GroupInner {
336 v: GROUP_INNER_VERSION,
337 group_id: gid.to_lowercase(),
338 from,
339 ts,
340 msg_id: msg_id.to_lowercase(),
341 body: body.to_string(),
342 sig: identity.sign_hex(&bytes),
343 };
344 Ok(serde_json::to_string(&inner)?)
345}
346
347pub fn unpack_group_inner(json: &str, expected_gid: &str) -> Result<GroupInner> {
348 let inner: GroupInner = serde_json::from_str(json)?;
349 if inner.v != GROUP_INNER_VERSION {
350 return Err(CoreError::InvalidInput(
351 "unsupported room inner version".into(),
352 ));
353 }
354 if inner.group_id != expected_gid.to_lowercase() {
355 return Err(CoreError::InvalidInput(
356 "room inner is for another room".into(),
357 ));
358 }
359 let gid = hex::decode(&inner.group_id)
360 .map_err(|_| CoreError::InvalidInput("room id is not hex".into()))?;
361 if gid.len() != GROUP_ID_BYTES {
362 return Err(CoreError::InvalidInput("room id must be 16 bytes".into()));
363 }
364 let from = normalize_pubkey_hex(&inner.from)?;
365 let msg_id = msg_id_bytes(&inner.msg_id)?;
366 if inner.body.is_empty() || inner.body.len() > crate::message::MAX_BODY_BYTES {
367 return Err(CoreError::InvalidInput(
368 "room body has an invalid size".into(),
369 ));
370 }
371 let mut bytes =
372 Vec::with_capacity(GROUP_MESSAGE_DOMAIN.len() + 16 + 32 + 8 + 16 + inner.body.len());
373 bytes.extend_from_slice(GROUP_MESSAGE_DOMAIN);
374 bytes.extend_from_slice(&gid);
375 bytes.extend_from_slice(&crate::ids::pubkey_bytes(&from)?);
376 bytes.extend_from_slice(&inner.ts.to_be_bytes());
377 bytes.extend_from_slice(&msg_id);
378 bytes.extend_from_slice(inner.body.as_bytes());
379 ChatIdentity::verify_hex(&from, &bytes, &inner.sig)?;
380 Ok(GroupInner { from, ..inner })
381}
382
383pub fn seal_group(
384 key_hex: &str,
385 gid: &str,
386 msg_id: &str,
387 inner_json: &str,
388) -> Result<SealedGroupMessage> {
389 let key = group_enc_key(key_hex)?;
390 let aad = group_aad(gid, msg_id)?;
391 let mut nonce = [0u8; 24];
392 rand::rngs::OsRng.fill_bytes(&mut nonce);
393 let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
394 let ciphertext = cipher
395 .encrypt(
396 XNonce::from_slice(&nonce),
397 Payload {
398 msg: inner_json.as_bytes(),
399 aad: &aad,
400 },
401 )
402 .map_err(|_| CoreError::Crypto("room seal failed".into()))?;
403 Ok(SealedGroupMessage {
404 v: GROUP_INNER_VERSION,
405 gid: gid.to_lowercase(),
406 msg_id: msg_id.to_lowercase(),
407 nonce: hex::encode(nonce),
408 ciphertext: hex::encode(ciphertext),
409 })
410}
411
412pub fn open_group(key_hex: &str, sealed: &SealedGroupMessage) -> Result<String> {
413 if sealed.v != GROUP_INNER_VERSION {
414 return Err(CoreError::Crypto("unsupported sealed room version".into()));
415 }
416 let key = group_enc_key(key_hex)?;
417 let aad = group_aad(&sealed.gid, &sealed.msg_id)?;
418 let nonce = hex::decode(sealed.nonce.trim())
419 .map_err(|_| CoreError::Crypto("room nonce is not hex".into()))?;
420 if nonce.len() != 24 {
421 return Err(CoreError::Crypto("room nonce must be 24 bytes".into()));
422 }
423 let ciphertext = hex::decode(sealed.ciphertext.trim())
424 .map_err(|_| CoreError::Crypto("room ciphertext is not hex".into()))?;
425 let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
426 let plain = cipher
427 .decrypt(
428 XNonce::from_slice(&nonce),
429 Payload {
430 msg: &ciphertext,
431 aad: &aad,
432 },
433 )
434 .map_err(|_| CoreError::Crypto("room message could not be opened".into()))?;
435 String::from_utf8(plain).map_err(|_| CoreError::InvalidInput("room inner is not UTF-8".into()))
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn member(seed: u8, name: &str) -> GroupMember {
443 GroupMember::new(
444 name,
445 &ChatIdentity::from_secret_bytes([seed; 32]).public_hex(),
446 &format!("{name}.enc@gw"),
447 )
448 .unwrap()
449 }
450
451 #[test]
452 fn group_seal_open_and_signature_round_trip() {
453 let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
454 let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
455 let own = member(1, "Alice");
456 let group = Group::create("Room", vec![member(2, "Bob")], own).unwrap();
457 let inner = pack_group_inner(&alice, &group.id, &"ab".repeat(16), 123, "hi room").unwrap();
458 let sealed = seal_group(&group.key, &group.id, &"ab".repeat(16), &inner).unwrap();
459 let opened = open_group(&group.key, &sealed).unwrap();
460 let verified = unpack_group_inner(&opened, &group.id).unwrap();
461 assert_eq!(verified.body, "hi room");
462 assert_eq!(verified.from, alice.public_hex());
463 assert!(unpack_group_inner(&opened, &"cd".repeat(16)).is_err());
464 assert!(open_group(&hex::encode([9u8; 32]), &sealed).is_err());
465
466 let wrong = pack_group_inner(&bob, &group.id, &"ef".repeat(16), 123, "spoof").unwrap();
467 let mut value: serde_json::Value = serde_json::from_str(&wrong).unwrap();
468 value["body"] = serde_json::json!("tampered");
469 assert!(unpack_group_inner(&value.to_string(), &group.id).is_err());
470 }
471
472 #[test]
473 fn invite_round_trips_and_binds_identity() {
474 let alice = ChatIdentity::from_secret_bytes([1u8; 32]);
475 let bob = ChatIdentity::from_secret_bytes([2u8; 32]);
476 let group = Group::create("Room", vec![member(2, "Bob")], member(1, "Alice")).unwrap();
477 let invite = group.invite(&alice).unwrap();
478 let joined = Group::from_invite(&invite, &bob.public_hex()).unwrap();
479 assert_eq!(joined.id, group.id);
480 assert_eq!(joined.key, group.key);
481 assert_eq!(joined.members.len(), 2);
482 assert!(Group::from_invite(&invite, &"aa".repeat(32)).is_err());
483 let carol = ChatIdentity::from_secret_bytes([9u8; 32]);
484 assert!(group.invite(&carol).is_err());
485 }
486}