slancha-wire 0.13.4

Magic-wormhole for AI agents — bilateral signed-message bus over a mailbox relay
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Group chat (v0.13.3) — signed member-set model.
//!
//! A group is a named, creator-signed set of members. Group membership is a
//! SEPARATE axis from bilateral peer trust: a member's [`GroupTier`] is
//! group-scoped (Creator / Member / Introduced) and is NOT the bilateral
//! `trust.rs` `Tier`. A peer can be bilaterally UNTRUSTED yet a group Member,
//! or VERIFIED bilaterally but only INTRODUCED in a group — the two ladders
//! are intentionally disjoint, and group membership never auto-promotes
//! bilateral trust.
//!
//! The creator signs the canonical roster (`creator_sig`), so a member can pin
//! INTRODUCED peers on the creator's vouch even when the creator is offline.
//! `epoch` bumps on every roster mutation — it orders revocations (a kick at
//! epoch N invalidates anything stamped < N).
//!
//! Persistence: `<config>/groups/<id>.json`. Transport (group send/tail, the
//! join code, kick/secure-eject) lives in `cli.rs` and composes the existing
//! mesh-broadcast + invite primitives over the member set this module owns.

use anyhow::{Context, Result, bail};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::PathBuf;

use crate::signing::{b64decode, b64encode, canonical_event};

/// Group-scoped membership tier. Disjoint from the bilateral `trust.rs` Tier.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupTier {
    /// Owns the group; the only signer of the roster.
    Creator,
    /// Added by the creator from a bilaterally-VERIFIED peer (T22 consent).
    Member,
    /// Joined via a multi-use code — vouched-for, lower-privilege, visible,
    /// kickable. Never silently equivalent to a directly-verified Member.
    Introduced,
}

impl GroupTier {
    pub fn as_str(self) -> &'static str {
        match self {
            GroupTier::Creator => "creator",
            GroupTier::Member => "member",
            GroupTier::Introduced => "introduced",
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Member {
    pub handle: String,
    /// Full DID — the identity anchor. Binding the member to its DID (not just
    /// the display handle) blocks a handle-spoof: two members can't collide on
    /// a handle, and a roster entry is pinned to one keypair.
    pub did: String,
    pub tier: GroupTier,
    /// Ed25519 key id (`<handle>:<fp>`). Part of the creator-signed roster so a
    /// member can introduce-pin this member's key on the creator's vouch.
    #[serde(default)]
    pub key_id: String,
    /// Base64 Ed25519 public key. The creator vouches for this binding via
    /// `creator_sig`; members pin it (at bilateral UNTRUSTED) to verify this
    /// member's group messages without a direct SAS handshake.
    #[serde(default)]
    pub key: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Group {
    pub id: String,
    pub name: String,
    pub creator_did: String,
    /// Bumped on every roster mutation (add/remove). Orders revocations (T17).
    pub epoch: u64,
    pub members: Vec<Member>,
    /// The shared group-room slot (I2). The creator allocates one relay slot;
    /// its token is the room key, distributed only to vouched members. Everyone
    /// posts + pulls this one slot. Empty until the room is allocated.
    #[serde(default)]
    pub relay_url: String,
    #[serde(default)]
    pub slot_id: String,
    /// Shared room key — read+write bearer credential for the group slot.
    /// SECRET: held only by vouched members; a leak compromises the room
    /// (revocation = rotate the slot, the I3 kick path).
    #[serde(default)]
    pub slot_token: String,
    /// Creator's Ed25519 signature (base64) over the canonical roster sans
    /// this field. Empty until signed.
    #[serde(default)]
    pub creator_sig: String,
}

impl Group {
    /// New group with the creator as the sole initial member. Unsigned — call
    /// [`Group::sign`] with the creator's private key.
    pub fn new(id: String, name: String, creator_handle: String, creator_did: String) -> Self {
        Group {
            members: vec![Member {
                handle: creator_handle,
                did: creator_did.clone(),
                tier: GroupTier::Creator,
                key_id: String::new(),
                key: String::new(),
            }],
            id,
            name,
            creator_did,
            epoch: 0,
            relay_url: String::new(),
            slot_id: String::new(),
            slot_token: String::new(),
            creator_sig: String::new(),
        }
    }

    /// Attach the relay-room coords (the shared group slot). Does NOT bump
    /// epoch — set as part of the create transaction, before signing.
    pub fn set_room(&mut self, relay_url: String, slot_id: String, slot_token: String) {
        self.relay_url = relay_url;
        self.slot_id = slot_id;
        self.slot_token = slot_token;
    }

    /// Attach a member's signing key by DID. Does NOT bump epoch — set as part
    /// of the add transaction, before signing. Errors if the DID isn't present.
    pub fn set_member_keys(&mut self, did: &str, key_id: String, key: String) -> Result<()> {
        let m = self
            .members
            .iter_mut()
            .find(|m| m.did == did)
            .with_context(|| format!("did {did} not in group {}", self.id))?;
        m.key_id = key_id;
        m.key = key;
        Ok(())
    }

    /// True if `did` is in the roster (any tier).
    pub fn contains_did(&self, did: &str) -> bool {
        self.members.iter().any(|m| m.did == did)
    }

    /// Member handles excluding self — the fan-out target for a group send.
    pub fn other_member_handles(&self, self_did: &str) -> Vec<String> {
        self.members
            .iter()
            .filter(|m| m.did != self_did)
            .map(|m| m.handle.clone())
            .collect()
    }

    /// Add a member at `tier`. Bumps `epoch` and INVALIDATES the signature
    /// (re-sign before persisting). Errors if the DID is already present.
    pub fn add_member(&mut self, handle: String, did: String, tier: GroupTier) -> Result<()> {
        if self.contains_did(&did) {
            bail!("did {did} already in group {}", self.id);
        }
        self.members.push(Member {
            handle,
            did,
            tier,
            key_id: String::new(),
            key: String::new(),
        });
        self.epoch += 1;
        self.creator_sig.clear();
        Ok(())
    }

    /// Remove a member by DID (kick). Bumps `epoch` (orders the revocation)
    /// and invalidates the signature. Refuses to remove the creator. Returns
    /// the removed member's handle.
    pub fn remove_member(&mut self, did: &str) -> Result<String> {
        if did == self.creator_did {
            bail!("cannot remove the group creator");
        }
        let idx = self
            .members
            .iter()
            .position(|m| m.did == did)
            .with_context(|| format!("did {did} not in group {}", self.id))?;
        let removed = self.members.remove(idx);
        self.epoch += 1;
        self.creator_sig.clear();
        Ok(removed.handle)
    }

    /// Canonical bytes signed by the creator — the group minus `creator_sig`.
    fn signing_bytes(&self) -> Vec<u8> {
        let payload = json!({
            "id": self.id,
            "name": self.name,
            "creator_did": self.creator_did,
            "epoch": self.epoch,
            "members": self.members,
            "relay_url": self.relay_url,
            "slot_id": self.slot_id,
            "slot_token": self.slot_token,
        });
        canonical_event(&payload, true)
    }

    /// Sign the roster with the creator's private key (32-byte seed).
    pub fn sign(&mut self, private_key: &[u8]) -> Result<()> {
        if private_key.len() < 32 {
            bail!("private key too short");
        }
        let mut sk_bytes = [0u8; 32];
        sk_bytes.copy_from_slice(&private_key[..32]);
        let sk = SigningKey::from_bytes(&sk_bytes);
        let sig = sk.sign(&self.signing_bytes());
        self.creator_sig = b64encode(&sig.to_bytes());
        Ok(())
    }

    /// Verify `creator_sig` against the creator's public key (32 bytes).
    pub fn verify(&self, creator_pubkey: &[u8]) -> bool {
        if self.creator_sig.is_empty() || creator_pubkey.len() != 32 {
            return false;
        }
        let mut pk = [0u8; 32];
        pk.copy_from_slice(creator_pubkey);
        let vk = match VerifyingKey::from_bytes(&pk) {
            Ok(v) => v,
            Err(_) => return false,
        };
        let sig_bytes = match b64decode(&self.creator_sig) {
            Ok(b) if b.len() == 64 => b,
            _ => return false,
        };
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        vk.verify(&self.signing_bytes(), &Signature::from_bytes(&sig_arr))
            .is_ok()
    }
}

/// `<config>/groups/`.
pub fn groups_dir() -> Result<PathBuf> {
    Ok(crate::config::config_dir()?.join("groups"))
}

fn group_path(id: &str) -> Result<PathBuf> {
    Ok(groups_dir()?.join(format!("{id}.json")))
}

/// Persist a group (atomic tmp+rename).
pub fn save_group(group: &Group) -> Result<()> {
    let dir = groups_dir()?;
    std::fs::create_dir_all(&dir).with_context(|| format!("creating {dir:?}"))?;
    let path = group_path(&group.id)?;
    let tmp = path.with_extension("json.tmp");
    let body = serde_json::to_vec_pretty(group)?;
    std::fs::write(&tmp, body).with_context(|| format!("writing {tmp:?}"))?;
    std::fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?}{path:?}"))?;
    Ok(())
}

/// Load a group by id.
pub fn load_group(id: &str) -> Result<Group> {
    let path = group_path(id)?;
    let bytes =
        std::fs::read(&path).with_context(|| format!("no such group {id:?} (at {path:?})"))?;
    serde_json::from_slice(&bytes).with_context(|| format!("parsing group {id:?}"))
}

/// List all persisted groups (skips unparseable files).
pub fn list_groups() -> Result<Vec<Group>> {
    let dir = groups_dir()?;
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in std::fs::read_dir(&dir)?.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        if let Ok(bytes) = std::fs::read(&path)
            && let Ok(g) = serde_json::from_slice::<Group>(&bytes)
        {
            out.push(g);
        }
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}

/// Resolve a group by id OR exact name. Errors if ambiguous/absent.
pub fn resolve_group(id_or_name: &str) -> Result<Group> {
    if let Ok(g) = load_group(id_or_name) {
        return Ok(g);
    }
    let matches: Vec<Group> = list_groups()?
        .into_iter()
        .filter(|g| g.name == id_or_name)
        .collect();
    match matches.len() {
        0 => bail!("no group with id or name {id_or_name:?}"),
        1 => Ok(matches.into_iter().next().unwrap()),
        n => bail!("{n} groups named {id_or_name:?} — use the group id"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::signing::generate_keypair;

    fn mk() -> (Group, Vec<u8>, Vec<u8>) {
        let (sk, pk) = generate_keypair();
        let g = Group::new(
            "g1abc".into(),
            "test-group".into(),
            "creator-nick".into(),
            "did:wire:creator-aaaaaaaa".into(),
        );
        (g, sk.to_vec(), pk.to_vec())
    }

    #[test]
    fn sign_then_verify_roundtrips() {
        let (mut g, sk, pk) = mk();
        g.sign(&sk).unwrap();
        assert!(g.verify(&pk), "freshly-signed roster must verify");
        assert!(!g.creator_sig.is_empty());
    }

    #[test]
    fn tamper_breaks_signature() {
        let (mut g, sk, pk) = mk();
        g.sign(&sk).unwrap();
        // Inject a member WITHOUT re-signing → signature no longer covers the roster.
        g.members.push(Member {
            handle: "intruder".into(),
            did: "did:wire:intruder-bbbbbbbb".into(),
            tier: GroupTier::Member,
            key_id: String::new(),
            key: String::new(),
        });
        assert!(!g.verify(&pk), "tampered roster must NOT verify");
    }

    #[test]
    fn wrong_key_does_not_verify() {
        let (mut g, sk, _pk) = mk();
        g.sign(&sk).unwrap();
        let (_sk2, pk2) = generate_keypair();
        assert!(!g.verify(&pk2), "a different pubkey must not verify");
    }

    #[test]
    fn add_member_bumps_epoch_and_invalidates_sig() {
        let (mut g, sk, _pk) = mk();
        g.sign(&sk).unwrap();
        assert_eq!(g.epoch, 0);
        g.add_member(
            "bob".into(),
            "did:wire:bob-cccccccc".into(),
            GroupTier::Member,
        )
        .unwrap();
        assert_eq!(g.epoch, 1, "add bumps epoch");
        assert!(g.creator_sig.is_empty(), "add invalidates the signature");
    }

    #[test]
    fn add_duplicate_did_rejected() {
        let (mut g, _sk, _pk) = mk();
        g.add_member("x".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
            .unwrap();
        assert!(
            g.add_member("x2".into(), "did:wire:x-dddddddd".into(), GroupTier::Member)
                .is_err(),
            "duplicate DID must be rejected"
        );
    }

    #[test]
    fn remove_member_bumps_epoch_refuses_creator() {
        let (mut g, _sk, _pk) = mk();
        g.add_member(
            "bob".into(),
            "did:wire:bob-eeeeeeee".into(),
            GroupTier::Member,
        )
        .unwrap();
        let e = g.epoch;
        let h = g.remove_member("did:wire:bob-eeeeeeee").unwrap();
        assert_eq!(h, "bob");
        assert_eq!(g.epoch, e + 1, "remove bumps epoch (orders the revocation)");
        assert!(
            g.remove_member("did:wire:creator-aaaaaaaa").is_err(),
            "must refuse to remove the creator"
        );
    }

    #[test]
    fn group_tier_is_not_the_bilateral_tier() {
        // Doctrine guard: GroupTier is its own enum, serialized lowercase, and
        // must never be confused with trust.rs Tier (UPPERCASE). A member's
        // group standing says nothing about bilateral trust.
        assert_eq!(GroupTier::Introduced.as_str(), "introduced");
        let j = serde_json::to_string(&GroupTier::Member).unwrap();
        assert_eq!(j, "\"member\"");
        assert_ne!(
            GroupTier::Member.as_str(),
            crate::trust::Tier::Verified.as_str()
        );
    }

    #[test]
    fn room_coords_and_member_keys_are_covered_by_the_signature() {
        // The creator vouches for the room coords + each member's key binding,
        // so tampering with either after signing must invalidate creator_sig.
        let (mut g, sk, pk) = mk();
        g.set_room(
            "https://wireup.net".into(),
            "slot-abc".into(),
            "tok-secret".into(),
        );
        g.add_member(
            "bob".into(),
            "did:wire:bob-12345678".into(),
            GroupTier::Member,
        )
        .unwrap();
        g.set_member_keys(
            "did:wire:bob-12345678",
            "bob:12345678".into(),
            "BOBKEY".into(),
        )
        .unwrap();
        g.sign(&sk).unwrap();
        assert!(g.verify(&pk), "signed roster with room + keys must verify");

        // Tamper the room key → verify fails.
        let mut g2 = g.clone();
        g2.slot_token = "stolen".into();
        assert!(
            !g2.verify(&pk),
            "swapping the room token must break the vouch"
        );

        // Tamper a member's pinned key → verify fails (handle-spoof / key-swap).
        let mut g3 = g.clone();
        g3.members[1].key = "ATTACKERKEY".into();
        assert!(
            !g3.verify(&pk),
            "swapping a member key must break the vouch"
        );
    }

    #[test]
    fn other_member_handles_excludes_self() {
        let (mut g, _sk, _pk) = mk();
        g.add_member(
            "bob".into(),
            "did:wire:bob-ffffffff".into(),
            GroupTier::Member,
        )
        .unwrap();
        let targets = g.other_member_handles("did:wire:creator-aaaaaaaa");
        assert_eq!(targets, vec!["bob".to_string()], "fan-out excludes self");
    }
}