1use 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#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum GroupTier {
32 Creator,
34 Member,
36 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 pub did: String,
58 pub tier: GroupTier,
59 #[serde(default)]
62 pub key_id: String,
63 #[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 pub epoch: u64,
77 pub members: Vec<Member>,
78 #[serde(default)]
82 pub relay_url: String,
83 #[serde(default)]
84 pub slot_id: String,
85 #[serde(default)]
89 pub slot_token: String,
90 #[serde(default)]
93 pub creator_sig: String,
94}
95
96impl Group {
97 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 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 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 pub fn contains_did(&self, did: &str) -> bool {
142 self.members.iter().any(|m| m.did == did)
143 }
144
145 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 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 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 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 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 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
240pub 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
249pub 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
261pub 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
269pub 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
291pub 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 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 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 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 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 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}