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