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
14/// Length of a room id in bytes (rendered as 32 hex characters).
15pub const GROUP_ID_BYTES: usize = 16;
16/// Length of a room key in bytes.
17pub const GROUP_KEY_BYTES: usize = 32;
18/// Hard cap on room size, including this identity. Every message costs one send
19/// per member, so rooms stay small.
20pub const MAX_GROUP_MEMBERS: usize = 16;
21/// Maximum room name length in characters.
22pub const MAX_GROUP_NAME_CHARS: usize = 80;
23/// Domain separator mixed into the room message signature.
24pub const GROUP_MESSAGE_DOMAIN: &[u8] = b"nysiris-chat-v1/group-message";
25/// Domain separator mixed into the signed room invite.
26pub const GROUP_INVITE_DOMAIN: &[u8] = b"nysiris-chat-v1/group-invite";
27/// Domain separator for the room message encryption key.
28pub const GROUP_ENC_DOMAIN: &[u8] = b"nysiris-chat-v1/group-enc";
29/// Version of the room inner envelope and invite format.
30pub const GROUP_INNER_VERSION: u8 = 1;
31
32type Blake2b256 = Blake2b<U32>;
33
34/// A member of a room, as recorded in the local roster and the signed invite.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct GroupMember {
37    /// Display name shown in the roster. Not authenticated beyond the invite.
38    pub petname: String,
39    /// ed25519 chat public key, lowercase hex.
40    pub pubkey: String,
41    /// Nym transport address used for fan-out.
42    pub nym_address: String,
43}
44
45impl GroupMember {
46    /// Validate and normalize a room member.
47    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    /// Build a room member from a saved contact.
68    pub fn from_contact(contact: &Contact) -> Result<Self> {
69        Self::new(&contact.petname, &contact.pubkey, &contact.nym_address)
70    }
71}
72
73/// A room as known on this device. There is no authoritative server roster;
74/// every member holds a signed copy of this state.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Group {
77    /// Room id, 16 bytes as lowercase hex.
78    pub id: String,
79    /// Room name shown in the UI.
80    pub name: String,
81    /// Shared room key as lowercase hex. Anyone holding it can read and post.
82    pub key: String,
83    /// Local copy of the member list, including this identity.
84    pub members: Vec<GroupMember>,
85    /// Unix milliseconds when the room was created.
86    pub created_at: u64,
87}
88
89impl Group {
90    /// Create a local room including `own` and at least one peer.
91    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    /// Conversation id used for this room in history: `group:<room id>`.
123    pub fn conversation_id(&self) -> String {
124        format!("group:{}", self.id)
125    }
126
127    /// Look up a member by public key.
128    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    /// Check the whole room state: id and key sizes, name bounds, member count,
134    /// and duplicate member keys.
135    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    /// Produce the signed invite that hands the room key to the other members.
165    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        // Keep the unsigned form deterministic for byte-exact verification.
174        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    /// Join a room from a signed invite. Fails unless this identity's public key
193    /// is in the invited member list and the signature verifies.
194    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/// Signed plaintext carried inside a sealed room message.
285#[derive(Clone, Debug, Serialize, Deserialize)]
286pub struct GroupInner {
287    /// Envelope version. Currently `1`.
288    pub v: u8,
289    /// Room id, lowercase hex.
290    pub group_id: String,
291    /// Sender ed25519 public key, lowercase hex.
292    pub from: String,
293    /// Sender-supplied Unix timestamp in milliseconds.
294    pub ts: u64,
295    /// Sender-generated message id, lowercase hex.
296    #[serde(rename = "msgId")]
297    pub msg_id: String,
298    /// Message text.
299    pub body: String,
300    /// ed25519 signature over the domain-separated room fields.
301    pub sig: String,
302}
303
304/// A room message sealed with the shared room key.
305#[derive(Clone, Debug, Serialize, Deserialize)]
306pub struct SealedGroupMessage {
307    /// Envelope version. Currently `1`.
308    pub v: u8,
309    /// Room id, lowercase hex.
310    pub gid: String,
311    /// Sender-generated message id, lowercase hex.
312    #[serde(rename = "msgId")]
313    pub msg_id: String,
314    /// XChaCha20-Poly1305 nonce, hex (24 bytes).
315    pub nonce: String,
316    /// Ciphertext, hex.
317    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
352/// Sign and JSON-pack the room inner envelope.
353///
354/// The signature covers the domain separator, room id, sender key, timestamp,
355/// message id, and body, so a member cannot relabel another member's message.
356pub 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
394/// Verify and unpack a room inner envelope, checking that it belongs to
395/// `expected_gid`. Fails closed on any defect.
396pub 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
432/// Seal a signed room inner envelope with the shared room key.
433///
434/// The room id and message id are bound as associated data, so a sealed message
435/// cannot be replayed into another room or under another id.
436pub 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
465/// Open a sealed room message with the shared room key, returning the inner
466/// JSON. The signature is verified separately by [`unpack_group_inner`].
467pub 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}