Skip to main content

nysiris_core/
group.rs

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    /// Create a local room including `own` and at least one peer.
69    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        // Keep the unsigned form deterministic for byte-exact verification.
147        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}