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
245/// Reject a group id that isn't a safe single filename component before it is
246/// interpolated into a path. A group id arrives attacker-controlled inside a
247/// join code (`cmd_group_join` deserializes the `Group` and TOFU-verifies it
248/// against the creator key carried IN the roster — so the crafter signs their
249/// own roster and `id` is fully attacker-chosen yet signature-valid). Without
250/// this guard, `id = "../trust"` writes `<config>/wire/trust.json`, an
251/// arbitrary-relative-path write / identity-store clobber. Legit ids are
252/// `g<16hex>` (see `cmd_group_create`); allow `[A-Za-z0-9_-]{1,64}`, which
253/// excludes `.`, `/`, `\`, and `..` by construction.
254fn validate_group_id(id: &str) -> Result<()> {
255    if id.is_empty()
256        || id.len() > 64
257        || !id
258            .bytes()
259            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
260    {
261        bail!("invalid group id {id:?} (must be 1-64 chars of [A-Za-z0-9_-])");
262    }
263    Ok(())
264}
265
266fn group_path(id: &str) -> Result<PathBuf> {
267    validate_group_id(id)?;
268    Ok(groups_dir()?.join(format!("{id}.json")))
269}
270
271/// Persist a group (atomic tmp+rename).
272pub fn save_group(group: &Group) -> Result<()> {
273    let dir = groups_dir()?;
274    std::fs::create_dir_all(&dir).with_context(|| format!("creating {dir:?}"))?;
275    let path = group_path(&group.id)?;
276    let tmp = path.with_extension("json.tmp");
277    let body = serde_json::to_vec_pretty(group)?;
278    std::fs::write(&tmp, body).with_context(|| format!("writing {tmp:?}"))?;
279    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
280    Ok(())
281}
282
283/// Load a group by id.
284pub fn load_group(id: &str) -> Result<Group> {
285    let path = group_path(id)?;
286    let bytes =
287        std::fs::read(&path).with_context(|| format!("no such group {id:?} (at {path:?})"))?;
288    serde_json::from_slice(&bytes).with_context(|| format!("parsing group {id:?}"))
289}
290
291/// List all persisted groups (skips unparseable files).
292pub fn list_groups() -> Result<Vec<Group>> {
293    let dir = groups_dir()?;
294    if !dir.exists() {
295        return Ok(Vec::new());
296    }
297    let mut out = Vec::new();
298    for entry in std::fs::read_dir(&dir)?.flatten() {
299        let path = entry.path();
300        if path.extension().and_then(|e| e.to_str()) != Some("json") {
301            continue;
302        }
303        if let Ok(bytes) = std::fs::read(&path)
304            && let Ok(g) = serde_json::from_slice::<Group>(&bytes)
305        {
306            out.push(g);
307        }
308    }
309    out.sort_by(|a, b| a.name.cmp(&b.name));
310    Ok(out)
311}
312
313/// Resolve a group by id OR exact name. Errors if ambiguous/absent.
314pub fn resolve_group(id_or_name: &str) -> Result<Group> {
315    if let Ok(g) = load_group(id_or_name) {
316        return Ok(g);
317    }
318    let matches: Vec<Group> = list_groups()?
319        .into_iter()
320        .filter(|g| g.name == id_or_name)
321        .collect();
322    match matches.len() {
323        0 => bail!("no group with id or name {id_or_name:?}"),
324        1 => Ok(matches.into_iter().next().unwrap()),
325        n => bail!("{n} groups named {id_or_name:?} — use the group id"),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::signing::generate_keypair;
333
334    fn mk() -> (Group, Vec<u8>, Vec<u8>) {
335        let (sk, pk) = generate_keypair();
336        let g = Group::new(
337            "g1abc".into(),
338            "test-group".into(),
339            "creator-nick".into(),
340            "did:wire:creator-aaaaaaaa".into(),
341        );
342        (g, sk.to_vec(), pk.to_vec())
343    }
344
345    #[test]
346    fn sign_then_verify_roundtrips() {
347        let (mut g, sk, pk) = mk();
348        g.sign(&sk).unwrap();
349        assert!(g.verify(&pk), "freshly-signed roster must verify");
350        assert!(!g.creator_sig.is_empty());
351    }
352
353    #[test]
354    fn tamper_breaks_signature() {
355        let (mut g, sk, pk) = mk();
356        g.sign(&sk).unwrap();
357        // Inject a member WITHOUT re-signing → signature no longer covers the roster.
358        g.members.push(Member {
359            handle: "intruder".into(),
360            did: "did:wire:intruder-bbbbbbbb".into(),
361            tier: GroupTier::Member,
362            key_id: String::new(),
363            key: String::new(),
364        });
365        assert!(!g.verify(&pk), "tampered roster must NOT verify");
366    }
367
368    #[test]
369    fn wrong_key_does_not_verify() {
370        let (mut g, sk, _pk) = mk();
371        g.sign(&sk).unwrap();
372        let (_sk2, pk2) = generate_keypair();
373        assert!(!g.verify(&pk2), "a different pubkey must not verify");
374    }
375
376    #[test]
377    fn add_member_bumps_epoch_and_invalidates_sig() {
378        let (mut g, sk, _pk) = mk();
379        g.sign(&sk).unwrap();
380        assert_eq!(g.epoch, 0);
381        g.add_member(
382            "bob".into(),
383            "did:wire:bob-cccccccc".into(),
384            GroupTier::Member,
385        )
386        .unwrap();
387        assert_eq!(g.epoch, 1, "add bumps epoch");
388        assert!(g.creator_sig.is_empty(), "add invalidates the signature");
389    }
390
391    #[test]
392    fn add_duplicate_did_rejected() {
393        let (mut g, _sk, _pk) = mk();
394        g.add_member("x".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
395            .unwrap();
396        assert!(
397            g.add_member("x2".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
398                .is_err(),
399            "duplicate DID must be rejected"
400        );
401    }
402
403    #[test]
404    fn remove_member_bumps_epoch_refuses_creator() {
405        let (mut g, _sk, _pk) = mk();
406        g.add_member(
407            "bob".into(),
408            "did:wire:bob-eeeeeeee".into(),
409            GroupTier::Member,
410        )
411        .unwrap();
412        let e = g.epoch;
413        let h = g.remove_member("did:wire:bob-eeeeeeee").unwrap();
414        assert_eq!(h, "bob");
415        assert_eq!(g.epoch, e + 1, "remove bumps epoch (orders the revocation)");
416        assert!(
417            g.remove_member("did:wire:creator-aaaaaaaa").is_err(),
418            "must refuse to remove the creator"
419        );
420    }
421
422    #[test]
423    fn group_tier_is_not_the_bilateral_tier() {
424        // Doctrine guard: GroupTier is its own enum, serialized lowercase, and
425        // must never be confused with trust.rs Tier (UPPERCASE). A member's
426        // group standing says nothing about bilateral trust.
427        assert_eq!(GroupTier::Introduced.as_str(), "introduced");
428        let j = serde_json::to_string(&GroupTier::Member).unwrap();
429        assert_eq!(j, "\"member\"");
430        assert_ne!(
431            GroupTier::Member.as_str(),
432            crate::trust::Tier::Verified.as_str()
433        );
434    }
435
436    #[test]
437    fn room_coords_and_member_keys_are_covered_by_the_signature() {
438        // The creator vouches for the room coords + each member's key binding,
439        // so tampering with either after signing must invalidate creator_sig.
440        let (mut g, sk, pk) = mk();
441        g.set_room(
442            "https://wireup.net".into(),
443            "slot-abc".into(),
444            "tok-secret".into(),
445        );
446        g.add_member(
447            "bob".into(),
448            "did:wire:bob-12345678".into(),
449            GroupTier::Member,
450        )
451        .unwrap();
452        g.set_member_keys(
453            "did:wire:bob-12345678",
454            "bob:12345678".into(),
455            "BOBKEY".into(),
456        )
457        .unwrap();
458        g.sign(&sk).unwrap();
459        assert!(g.verify(&pk), "signed roster with room + keys must verify");
460
461        // Tamper the room key → verify fails.
462        let mut g2 = g.clone();
463        g2.slot_token = "stolen".into();
464        assert!(
465            !g2.verify(&pk),
466            "swapping the room token must break the vouch"
467        );
468
469        // Tamper a member's pinned key → verify fails (handle-spoof / key-swap).
470        let mut g3 = g.clone();
471        g3.members[1].key = "ATTACKERKEY".into();
472        assert!(
473            !g3.verify(&pk),
474            "swapping a member key must break the vouch"
475        );
476    }
477
478    #[test]
479    fn other_member_handles_excludes_self() {
480        let (mut g, _sk, _pk) = mk();
481        g.add_member(
482            "bob".into(),
483            "did:wire:bob-ffffffff".into(),
484            GroupTier::Member,
485        )
486        .unwrap();
487        let targets = g.other_member_handles("did:wire:creator-aaaaaaaa");
488        assert_eq!(targets, vec!["bob".to_string()], "fan-out excludes self");
489    }
490
491    #[test]
492    fn group_id_path_traversal_is_rejected() {
493        // A malicious join code carries an attacker-chosen `group.id` that is
494        // signature-valid (self-signed roster). The path boundary MUST reject
495        // anything that isn't a safe filename component, BEFORE any FS op — so
496        // `id = "../trust"` can't clobber `<config>/wire/trust.json`.
497        for bad in [
498            "../trust",
499            "../../config/wire/agent-card",
500            "a/b",
501            "a\\b",
502            "..",
503            ".",
504            "a.b",
505            "",
506            &"x".repeat(65),
507        ] {
508            assert!(
509                group_path(bad).is_err(),
510                "path traversal not rejected: {bad:?}"
511            );
512            assert!(validate_group_id(bad).is_err(), "id not rejected: {bad:?}");
513        }
514        // Legit ids (the `g<16hex>` form from cmd_group_create) pass.
515        for ok in ["g0123456789abcdef", "my-group_1", "G9"] {
516            assert!(group_path(ok).is_ok(), "legit id rejected: {ok:?}");
517        }
518    }
519}