Skip to main content

wire/
group.rs

1//! Group chat (v0.13.3) — signed member-set model.
2//!
3//! A group is a named, creator-signed set of members. Group membership is a
4//! SEPARATE axis from bilateral peer trust: a member's [`GroupTier`] is
5//! group-scoped (Creator / Member / Introduced) and is NOT the bilateral
6//! `trust.rs` `Tier`. A peer can be bilaterally UNTRUSTED yet a group Member,
7//! or VERIFIED bilaterally but only INTRODUCED in a group — the two ladders
8//! are intentionally disjoint, and group membership never auto-promotes
9//! bilateral trust.
10//!
11//! The creator signs the canonical roster (`creator_sig`), so a member can pin
12//! INTRODUCED peers on the creator's vouch even when the creator is offline.
13//! `epoch` bumps on every roster mutation — it orders revocations (a kick at
14//! epoch N invalidates anything stamped < N).
15//!
16//! Persistence: `<config>/groups/<id>.json`. Transport (group send/tail, the
17//! join code, kick/secure-eject) lives in `cli.rs` and composes the existing
18//! mesh-broadcast + invite primitives over the member set this module owns.
19
20use anyhow::{Context, Result, bail};
21use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24use std::path::PathBuf;
25
26use crate::signing::{b64decode, b64encode, canonical_event};
27
28/// Group-scoped membership tier. Disjoint from the bilateral `trust.rs` Tier.
29#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum GroupTier {
32    /// Owns the group; the only signer of the roster.
33    Creator,
34    /// Added by the creator from a bilaterally-VERIFIED peer (T22 consent).
35    Member,
36    /// Joined via a multi-use code — vouched-for, lower-privilege, visible,
37    /// kickable. Never silently equivalent to a directly-verified Member.
38    Introduced,
39}
40
41impl GroupTier {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            GroupTier::Creator => "creator",
45            GroupTier::Member => "member",
46            GroupTier::Introduced => "introduced",
47        }
48    }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Member {
53    pub handle: String,
54    /// Full DID — the identity anchor. Binding the member to its DID (not just
55    /// the display handle) blocks a handle-spoof: two members can't collide on
56    /// a handle, and a roster entry is pinned to one keypair.
57    pub did: String,
58    pub tier: GroupTier,
59    /// Ed25519 key id (`<handle>:<fp>`). Part of the creator-signed roster so a
60    /// member can introduce-pin this member's key on the creator's vouch.
61    #[serde(default)]
62    pub key_id: String,
63    /// Base64 Ed25519 public key. The creator vouches for this binding via
64    /// `creator_sig`; members pin it (at bilateral UNTRUSTED) to verify this
65    /// member's group messages without a direct SAS handshake.
66    #[serde(default)]
67    pub key: String,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct Group {
72    pub id: String,
73    pub name: String,
74    pub creator_did: String,
75    /// Bumped on every roster mutation (add/remove). Orders revocations (T17).
76    pub epoch: u64,
77    pub members: Vec<Member>,
78    /// The shared group-room slot (I2). The creator allocates one relay slot;
79    /// its token is the room key, distributed only to vouched members. Everyone
80    /// posts + pulls this one slot. Empty until the room is allocated.
81    #[serde(default)]
82    pub relay_url: String,
83    #[serde(default)]
84    pub slot_id: String,
85    /// Shared room key — read+write bearer credential for the group slot.
86    /// SECRET: held only by vouched members; a leak compromises the room
87    /// (revocation = rotate the slot, the I3 kick path).
88    #[serde(default)]
89    pub slot_token: String,
90    /// Creator's Ed25519 signature (base64) over the canonical roster sans
91    /// this field. Empty until signed.
92    #[serde(default)]
93    pub creator_sig: String,
94}
95
96impl Group {
97    /// New group with the creator as the sole initial member. Unsigned — call
98    /// [`Group::sign`] with the creator's private key.
99    pub fn new(id: String, name: String, creator_handle: String, creator_did: String) -> Self {
100        Group {
101            members: vec![Member {
102                handle: creator_handle,
103                did: creator_did.clone(),
104                tier: GroupTier::Creator,
105                key_id: String::new(),
106                key: String::new(),
107            }],
108            id,
109            name,
110            creator_did,
111            epoch: 0,
112            relay_url: String::new(),
113            slot_id: String::new(),
114            slot_token: String::new(),
115            creator_sig: String::new(),
116        }
117    }
118
119    /// Attach the relay-room coords (the shared group slot). Does NOT bump
120    /// epoch — set as part of the create transaction, before signing.
121    pub fn set_room(&mut self, relay_url: String, slot_id: String, slot_token: String) {
122        self.relay_url = relay_url;
123        self.slot_id = slot_id;
124        self.slot_token = slot_token;
125    }
126
127    /// Attach a member's signing key by DID. Does NOT bump epoch — set as part
128    /// of the add transaction, before signing. Errors if the DID isn't present.
129    pub fn set_member_keys(&mut self, did: &str, key_id: String, key: String) -> Result<()> {
130        let m = self
131            .members
132            .iter_mut()
133            .find(|m| m.did == did)
134            .with_context(|| format!("did {did} not in group {}", self.id))?;
135        m.key_id = key_id;
136        m.key = key;
137        Ok(())
138    }
139
140    /// True if `did` is in the roster (any tier).
141    pub fn contains_did(&self, did: &str) -> bool {
142        self.members.iter().any(|m| m.did == did)
143    }
144
145    /// Member handles excluding self — the fan-out target for a group send.
146    pub fn other_member_handles(&self, self_did: &str) -> Vec<String> {
147        self.members
148            .iter()
149            .filter(|m| m.did != self_did)
150            .map(|m| m.handle.clone())
151            .collect()
152    }
153
154    /// Add a member at `tier`. Bumps `epoch` and INVALIDATES the signature
155    /// (re-sign before persisting). Errors if the DID is already present.
156    pub fn add_member(&mut self, handle: String, did: String, tier: GroupTier) -> Result<()> {
157        if self.contains_did(&did) {
158            bail!("did {did} already in group {}", self.id);
159        }
160        self.members.push(Member {
161            handle,
162            did,
163            tier,
164            key_id: String::new(),
165            key: String::new(),
166        });
167        self.epoch += 1;
168        self.creator_sig.clear();
169        Ok(())
170    }
171
172    /// Remove a member by DID (kick). Bumps `epoch` (orders the revocation)
173    /// and invalidates the signature. Refuses to remove the creator. Returns
174    /// the removed member's handle.
175    pub fn remove_member(&mut self, did: &str) -> Result<String> {
176        if did == self.creator_did {
177            bail!("cannot remove the group creator");
178        }
179        let idx = self
180            .members
181            .iter()
182            .position(|m| m.did == did)
183            .with_context(|| format!("did {did} not in group {}", self.id))?;
184        let removed = self.members.remove(idx);
185        self.epoch += 1;
186        self.creator_sig.clear();
187        Ok(removed.handle)
188    }
189
190    /// Canonical bytes signed by the creator — the group minus `creator_sig`.
191    fn signing_bytes(&self) -> Vec<u8> {
192        let payload = json!({
193            "id": self.id,
194            "name": self.name,
195            "creator_did": self.creator_did,
196            "epoch": self.epoch,
197            "members": self.members,
198            "relay_url": self.relay_url,
199            "slot_id": self.slot_id,
200            "slot_token": self.slot_token,
201        });
202        canonical_event(&payload, true)
203    }
204
205    /// Sign the roster with the creator's private key (32-byte seed).
206    pub fn sign(&mut self, private_key: &[u8]) -> Result<()> {
207        if private_key.len() < 32 {
208            bail!("private key too short");
209        }
210        let mut sk_bytes = [0u8; 32];
211        sk_bytes.copy_from_slice(&private_key[..32]);
212        let sk = SigningKey::from_bytes(&sk_bytes);
213        let sig = sk.sign(&self.signing_bytes());
214        self.creator_sig = b64encode(&sig.to_bytes());
215        Ok(())
216    }
217
218    /// Verify `creator_sig` against the creator's public key (32 bytes).
219    pub fn verify(&self, creator_pubkey: &[u8]) -> bool {
220        if self.creator_sig.is_empty() || creator_pubkey.len() != 32 {
221            return false;
222        }
223        let mut pk = [0u8; 32];
224        pk.copy_from_slice(creator_pubkey);
225        let vk = match VerifyingKey::from_bytes(&pk) {
226            Ok(v) => v,
227            Err(_) => return false,
228        };
229        let sig_bytes = match b64decode(&self.creator_sig) {
230            Ok(b) if b.len() == 64 => b,
231            _ => return false,
232        };
233        let mut sig_arr = [0u8; 64];
234        sig_arr.copy_from_slice(&sig_bytes);
235        vk.verify(&self.signing_bytes(), &Signature::from_bytes(&sig_arr))
236            .is_ok()
237    }
238}
239
240/// `<config>/groups/`.
241pub fn groups_dir() -> Result<PathBuf> {
242    Ok(crate::config::config_dir()?.join("groups"))
243}
244
245fn group_path(id: &str) -> Result<PathBuf> {
246    Ok(groups_dir()?.join(format!("{id}.json")))
247}
248
249/// Persist a group (atomic tmp+rename).
250pub fn save_group(group: &Group) -> Result<()> {
251    let dir = groups_dir()?;
252    std::fs::create_dir_all(&dir).with_context(|| format!("creating {dir:?}"))?;
253    let path = group_path(&group.id)?;
254    let tmp = path.with_extension("json.tmp");
255    let body = serde_json::to_vec_pretty(group)?;
256    std::fs::write(&tmp, body).with_context(|| format!("writing {tmp:?}"))?;
257    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
258    Ok(())
259}
260
261/// Load a group by id.
262pub fn load_group(id: &str) -> Result<Group> {
263    let path = group_path(id)?;
264    let bytes =
265        std::fs::read(&path).with_context(|| format!("no such group {id:?} (at {path:?})"))?;
266    serde_json::from_slice(&bytes).with_context(|| format!("parsing group {id:?}"))
267}
268
269/// List all persisted groups (skips unparseable files).
270pub fn list_groups() -> Result<Vec<Group>> {
271    let dir = groups_dir()?;
272    if !dir.exists() {
273        return Ok(Vec::new());
274    }
275    let mut out = Vec::new();
276    for entry in std::fs::read_dir(&dir)?.flatten() {
277        let path = entry.path();
278        if path.extension().and_then(|e| e.to_str()) != Some("json") {
279            continue;
280        }
281        if let Ok(bytes) = std::fs::read(&path)
282            && let Ok(g) = serde_json::from_slice::<Group>(&bytes)
283        {
284            out.push(g);
285        }
286    }
287    out.sort_by(|a, b| a.name.cmp(&b.name));
288    Ok(out)
289}
290
291/// Resolve a group by id OR exact name. Errors if ambiguous/absent.
292pub fn resolve_group(id_or_name: &str) -> Result<Group> {
293    if let Ok(g) = load_group(id_or_name) {
294        return Ok(g);
295    }
296    let matches: Vec<Group> = list_groups()?
297        .into_iter()
298        .filter(|g| g.name == id_or_name)
299        .collect();
300    match matches.len() {
301        0 => bail!("no group with id or name {id_or_name:?}"),
302        1 => Ok(matches.into_iter().next().unwrap()),
303        n => bail!("{n} groups named {id_or_name:?} — use the group id"),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::signing::generate_keypair;
311
312    fn mk() -> (Group, Vec<u8>, Vec<u8>) {
313        let (sk, pk) = generate_keypair();
314        let g = Group::new(
315            "g1abc".into(),
316            "test-group".into(),
317            "creator-nick".into(),
318            "did:wire:creator-aaaaaaaa".into(),
319        );
320        (g, sk.to_vec(), pk.to_vec())
321    }
322
323    #[test]
324    fn sign_then_verify_roundtrips() {
325        let (mut g, sk, pk) = mk();
326        g.sign(&sk).unwrap();
327        assert!(g.verify(&pk), "freshly-signed roster must verify");
328        assert!(!g.creator_sig.is_empty());
329    }
330
331    #[test]
332    fn tamper_breaks_signature() {
333        let (mut g, sk, pk) = mk();
334        g.sign(&sk).unwrap();
335        // Inject a member WITHOUT re-signing → signature no longer covers the roster.
336        g.members.push(Member {
337            handle: "intruder".into(),
338            did: "did:wire:intruder-bbbbbbbb".into(),
339            tier: GroupTier::Member,
340            key_id: String::new(),
341            key: String::new(),
342        });
343        assert!(!g.verify(&pk), "tampered roster must NOT verify");
344    }
345
346    #[test]
347    fn wrong_key_does_not_verify() {
348        let (mut g, sk, _pk) = mk();
349        g.sign(&sk).unwrap();
350        let (_sk2, pk2) = generate_keypair();
351        assert!(!g.verify(&pk2), "a different pubkey must not verify");
352    }
353
354    #[test]
355    fn add_member_bumps_epoch_and_invalidates_sig() {
356        let (mut g, sk, _pk) = mk();
357        g.sign(&sk).unwrap();
358        assert_eq!(g.epoch, 0);
359        g.add_member(
360            "bob".into(),
361            "did:wire:bob-cccccccc".into(),
362            GroupTier::Member,
363        )
364        .unwrap();
365        assert_eq!(g.epoch, 1, "add bumps epoch");
366        assert!(g.creator_sig.is_empty(), "add invalidates the signature");
367    }
368
369    #[test]
370    fn add_duplicate_did_rejected() {
371        let (mut g, _sk, _pk) = mk();
372        g.add_member("x".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
373            .unwrap();
374        assert!(
375            g.add_member("x2".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
376                .is_err(),
377            "duplicate DID must be rejected"
378        );
379    }
380
381    #[test]
382    fn remove_member_bumps_epoch_refuses_creator() {
383        let (mut g, _sk, _pk) = mk();
384        g.add_member(
385            "bob".into(),
386            "did:wire:bob-eeeeeeee".into(),
387            GroupTier::Member,
388        )
389        .unwrap();
390        let e = g.epoch;
391        let h = g.remove_member("did:wire:bob-eeeeeeee").unwrap();
392        assert_eq!(h, "bob");
393        assert_eq!(g.epoch, e + 1, "remove bumps epoch (orders the revocation)");
394        assert!(
395            g.remove_member("did:wire:creator-aaaaaaaa").is_err(),
396            "must refuse to remove the creator"
397        );
398    }
399
400    #[test]
401    fn group_tier_is_not_the_bilateral_tier() {
402        // Doctrine guard: GroupTier is its own enum, serialized lowercase, and
403        // must never be confused with trust.rs Tier (UPPERCASE). A member's
404        // group standing says nothing about bilateral trust.
405        assert_eq!(GroupTier::Introduced.as_str(), "introduced");
406        let j = serde_json::to_string(&GroupTier::Member).unwrap();
407        assert_eq!(j, "\"member\"");
408        assert_ne!(
409            GroupTier::Member.as_str(),
410            crate::trust::Tier::Verified.as_str()
411        );
412    }
413
414    #[test]
415    fn room_coords_and_member_keys_are_covered_by_the_signature() {
416        // The creator vouches for the room coords + each member's key binding,
417        // so tampering with either after signing must invalidate creator_sig.
418        let (mut g, sk, pk) = mk();
419        g.set_room(
420            "https://wireup.net".into(),
421            "slot-abc".into(),
422            "tok-secret".into(),
423        );
424        g.add_member(
425            "bob".into(),
426            "did:wire:bob-12345678".into(),
427            GroupTier::Member,
428        )
429        .unwrap();
430        g.set_member_keys(
431            "did:wire:bob-12345678",
432            "bob:12345678".into(),
433            "BOBKEY".into(),
434        )
435        .unwrap();
436        g.sign(&sk).unwrap();
437        assert!(g.verify(&pk), "signed roster with room + keys must verify");
438
439        // Tamper the room key → verify fails.
440        let mut g2 = g.clone();
441        g2.slot_token = "stolen".into();
442        assert!(
443            !g2.verify(&pk),
444            "swapping the room token must break the vouch"
445        );
446
447        // Tamper a member's pinned key → verify fails (handle-spoof / key-swap).
448        let mut g3 = g.clone();
449        g3.members[1].key = "ATTACKERKEY".into();
450        assert!(
451            !g3.verify(&pk),
452            "swapping a member key must break the vouch"
453        );
454    }
455
456    #[test]
457    fn other_member_handles_excludes_self() {
458        let (mut g, _sk, _pk) = mk();
459        g.add_member(
460            "bob".into(),
461            "did:wire:bob-ffffffff".into(),
462            GroupTier::Member,
463        )
464        .unwrap();
465        let targets = g.other_member_handles("did:wire:creator-aaaaaaaa");
466        assert_eq!(targets, vec!["bob".to_string()], "fan-out excludes self");
467    }
468}