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 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
271pub 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
283pub 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
291pub 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
313pub 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 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 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 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 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 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 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 for ok in ["g0123456789abcdef", "my-group_1", "G9"] {
516 assert!(group_path(ok).is_ok(), "legit id rejected: {ok:?}");
517 }
518 }
519}