coven/sync/
membership.rs

1/// Membership chain: an append-only log of membership changes for shared libraries.
2///
3/// The chain is stored as encrypted files in sync storage and reconstructed
4/// on each sync. It is not stored in the DB.
5///
6/// Layout in storage:
7/// ```text
8/// membership/{author_pubkey_hex}/{seq}.enc
9/// ```
10///
11/// Each entry records an Add or Remove action, signed by a current owner.
12/// The first entry must be a self-signed Add with role Owner.
13use serde::{Deserialize, Serialize};
14
15use crate::keys::{self, UserKeypair};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub enum MembershipAction {
19    Add,
20    Remove,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub enum MemberRole {
25    Owner,
26    Member,
27}
28
29/// A single membership entry in the chain.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MembershipEntry {
32    pub action: MembershipAction,
33    pub user_pubkey: String,
34    pub role: MemberRole,
35    pub timestamp: String,
36    pub author_pubkey: String,
37    pub signature: String,
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum MembershipError {
42    #[error("first entry must be a self-signed owner Add")]
43    InvalidFirstEntry,
44    #[error("entry at index {0} has an invalid signature")]
45    InvalidSignature(usize),
46    #[error("entry at index {0}: author is not an owner at that point in the chain")]
47    NotAnOwner(usize),
48    #[error("chain is empty")]
49    EmptyChain,
50}
51
52/// Deterministic serialization of the signed fields (everything except signature).
53pub fn canonical_bytes(entry: &MembershipEntry) -> Vec<u8> {
54    // Use serde_json::json! with explicit field ordering for determinism.
55    // JSON object keys from json! macro are sorted alphabetically by serde_json.
56    let canonical = serde_json::json!({
57        "action": entry.action,
58        "author_pubkey": entry.author_pubkey,
59        "role": entry.role,
60        "timestamp": entry.timestamp,
61        "user_pubkey": entry.user_pubkey,
62    });
63    serde_json::to_vec(&canonical).expect("canonical serialization cannot fail")
64}
65
66/// Sign a membership entry with the given keypair.
67///
68/// Sets `author_pubkey` and `signature` on the entry.
69pub fn sign_membership_entry(entry: &mut MembershipEntry, keypair: &UserKeypair) {
70    entry.author_pubkey = hex::encode(keypair.public_key);
71    let bytes = canonical_bytes(entry);
72    let sig = keypair.sign(&bytes);
73    entry.signature = hex::encode(sig);
74}
75
76/// Verify the signature on a membership entry.
77pub fn verify_membership_entry(entry: &MembershipEntry) -> bool {
78    let Ok(pk_bytes) = hex::decode(&entry.author_pubkey) else {
79        return false;
80    };
81    let Ok(sig_bytes) = hex::decode(&entry.signature) else {
82        return false;
83    };
84
85    let Ok(pk): Result<[u8; keys::SIGN_PUBLICKEYBYTES], _> = pk_bytes.try_into() else {
86        return false;
87    };
88    let Ok(sig): Result<[u8; keys::SIGN_BYTES], _> = sig_bytes.try_into() else {
89        return false;
90    };
91
92    let bytes = canonical_bytes(entry);
93    keys::verify_signature(&sig, &bytes, &pk)
94}
95
96/// An append-only membership chain.
97///
98/// Entries are sorted by timestamp (HLC string comparison gives causal order).
99#[derive(Debug, Clone, Default)]
100pub struct MembershipChain {
101    entries: Vec<MembershipEntry>,
102}
103
104impl MembershipChain {
105    /// Create an empty chain.
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Create a chain from existing entries (e.g., downloaded from storage).
111    /// Entries are sorted by timestamp and validated on construction.
112    pub fn from_entries(mut entries: Vec<MembershipEntry>) -> Result<Self, MembershipError> {
113        entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
114        let chain = Self { entries };
115        chain.validate()?;
116        Ok(chain)
117    }
118
119    /// Return the entries in the chain.
120    pub fn entries(&self) -> &[MembershipEntry] {
121        &self.entries
122    }
123
124    /// Validate the entire chain.
125    ///
126    /// Rules:
127    /// 1. First entry must be Add with role Owner, self-signed.
128    /// 2. Every entry must have a valid signature.
129    /// 3. Every entry's author must be a current Owner at that point.
130    pub fn validate(&self) -> Result<(), MembershipError> {
131        if self.entries.is_empty() {
132            return Err(MembershipError::EmptyChain);
133        }
134
135        let first = &self.entries[0];
136        if first.action != MembershipAction::Add
137            || first.role != MemberRole::Owner
138            || first.author_pubkey != first.user_pubkey
139        {
140            return Err(MembershipError::InvalidFirstEntry);
141        }
142
143        if !verify_membership_entry(first) {
144            return Err(MembershipError::InvalidSignature(0));
145        }
146
147        // Track active members as we walk the chain.
148        let mut active: Vec<(String, MemberRole)> = vec![];
149        active.push((first.user_pubkey.clone(), first.role.clone()));
150
151        for (i, entry) in self.entries.iter().enumerate().skip(1) {
152            if !verify_membership_entry(entry) {
153                return Err(MembershipError::InvalidSignature(i));
154            }
155
156            // Author must be an active owner.
157            let is_owner = active
158                .iter()
159                .any(|(pk, role)| pk == &entry.author_pubkey && *role == MemberRole::Owner);
160
161            if !is_owner {
162                return Err(MembershipError::NotAnOwner(i));
163            }
164
165            match entry.action {
166                MembershipAction::Add => {
167                    // Remove any existing entry for this pubkey (role change).
168                    active.retain(|(pk, _)| pk != &entry.user_pubkey);
169                    active.push((entry.user_pubkey.clone(), entry.role.clone()));
170                }
171                MembershipAction::Remove => {
172                    active.retain(|(pk, _)| pk != &entry.user_pubkey);
173                }
174            }
175        }
176
177        Ok(())
178    }
179
180    /// Check if a pubkey was an active member at the given timestamp.
181    ///
182    /// Replays entries up to and including the given timestamp.
183    pub fn is_member_at(&self, pubkey: &str, timestamp: &str) -> bool {
184        let mut active: Vec<String> = Vec::new();
185
186        for entry in &self.entries {
187            if entry.timestamp.as_str() > timestamp {
188                break;
189            }
190
191            match entry.action {
192                MembershipAction::Add => {
193                    if !active.contains(&entry.user_pubkey) {
194                        active.push(entry.user_pubkey.clone());
195                    }
196                }
197                MembershipAction::Remove => {
198                    active.retain(|pk| pk != &entry.user_pubkey);
199                }
200            }
201        }
202
203        active.contains(&pubkey.to_string())
204    }
205
206    /// Return current active members with their roles.
207    pub fn current_members(&self) -> Vec<(String, MemberRole)> {
208        let mut active: Vec<(String, MemberRole)> = Vec::new();
209
210        for entry in &self.entries {
211            match entry.action {
212                MembershipAction::Add => {
213                    active.retain(|(pk, _)| pk != &entry.user_pubkey);
214                    active.push((entry.user_pubkey.clone(), entry.role.clone()));
215                }
216                MembershipAction::Remove => {
217                    active.retain(|(pk, _)| pk != &entry.user_pubkey);
218                }
219            }
220        }
221
222        active
223    }
224
225    /// Validate and append an entry to the chain.
226    pub fn add_entry(&mut self, entry: MembershipEntry) -> Result<(), MembershipError> {
227        if self.entries.is_empty() {
228            // First entry: must be self-signed owner Add.
229            if entry.action != MembershipAction::Add
230                || entry.role != MemberRole::Owner
231                || entry.author_pubkey != entry.user_pubkey
232            {
233                return Err(MembershipError::InvalidFirstEntry);
234            }
235
236            if !verify_membership_entry(&entry) {
237                return Err(MembershipError::InvalidSignature(0));
238            }
239
240            self.entries.push(entry);
241            return Ok(());
242        }
243
244        if !verify_membership_entry(&entry) {
245            return Err(MembershipError::InvalidSignature(self.entries.len()));
246        }
247
248        // Author must be an active owner.
249        let members = self.current_members();
250        let is_owner = members
251            .iter()
252            .any(|(pk, role)| pk == &entry.author_pubkey && *role == MemberRole::Owner);
253
254        if !is_owner {
255            return Err(MembershipError::NotAnOwner(self.entries.len()));
256        }
257
258        self.entries.push(entry);
259        Ok(())
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::keys::UserKeypair;
267
268    fn gen_keypair() -> UserKeypair {
269        UserKeypair::generate()
270    }
271
272    fn pubkey_hex(kp: &UserKeypair) -> String {
273        hex::encode(kp.public_key)
274    }
275
276    /// Create a signed "founder" entry (first entry in the chain).
277    fn founder_entry(kp: &UserKeypair, timestamp: &str) -> MembershipEntry {
278        let pk_hex = pubkey_hex(kp);
279        let mut entry = MembershipEntry {
280            action: MembershipAction::Add,
281            user_pubkey: pk_hex.clone(),
282            role: MemberRole::Owner,
283            timestamp: timestamp.to_string(),
284            author_pubkey: pk_hex,
285            signature: String::new(),
286        };
287        sign_membership_entry(&mut entry, kp);
288        entry
289    }
290
291    /// Create a signed entry where `author` adds/removes `subject`.
292    fn make_entry(
293        author: &UserKeypair,
294        action: MembershipAction,
295        subject: &UserKeypair,
296        role: MemberRole,
297        timestamp: &str,
298    ) -> MembershipEntry {
299        let mut entry = MembershipEntry {
300            action,
301            user_pubkey: pubkey_hex(subject),
302            role,
303            timestamp: timestamp.to_string(),
304            author_pubkey: pubkey_hex(author),
305            signature: String::new(),
306        };
307        sign_membership_entry(&mut entry, author);
308        entry
309    }
310
311    #[test]
312    fn single_owner_chain() {
313        let owner = gen_keypair();
314        let entry = founder_entry(&owner, "0000000001000-0000-dev1");
315
316        let mut chain = MembershipChain::new();
317        chain.add_entry(entry).unwrap();
318        chain.validate().unwrap();
319
320        let members = chain.current_members();
321        assert_eq!(members.len(), 1);
322        assert_eq!(members[0].0, pubkey_hex(&owner));
323        assert_eq!(members[0].1, MemberRole::Owner);
324    }
325
326    #[test]
327    fn add_member_signed_by_owner() {
328        let owner = gen_keypair();
329        let member = gen_keypair();
330
331        let mut chain = MembershipChain::new();
332        chain
333            .add_entry(founder_entry(&owner, "0000000001000-0000-dev1"))
334            .unwrap();
335        chain
336            .add_entry(make_entry(
337                &owner,
338                MembershipAction::Add,
339                &member,
340                MemberRole::Member,
341                "0000000002000-0000-dev1",
342            ))
343            .unwrap();
344
345        chain.validate().unwrap();
346
347        let members = chain.current_members();
348        assert_eq!(members.len(), 2);
349        assert!(members
350            .iter()
351            .any(|(pk, r)| pk == &pubkey_hex(&owner) && *r == MemberRole::Owner));
352        assert!(members
353            .iter()
354            .any(|(pk, r)| pk == &pubkey_hex(&member) && *r == MemberRole::Member));
355    }
356
357    #[test]
358    fn remove_member_signed_by_owner() {
359        let owner = gen_keypair();
360        let member = gen_keypair();
361
362        let mut chain = MembershipChain::new();
363        chain
364            .add_entry(founder_entry(&owner, "0000000001000-0000-dev1"))
365            .unwrap();
366        chain
367            .add_entry(make_entry(
368                &owner,
369                MembershipAction::Add,
370                &member,
371                MemberRole::Member,
372                "0000000002000-0000-dev1",
373            ))
374            .unwrap();
375        chain
376            .add_entry(make_entry(
377                &owner,
378                MembershipAction::Remove,
379                &member,
380                MemberRole::Member,
381                "0000000003000-0000-dev1",
382            ))
383            .unwrap();
384
385        chain.validate().unwrap();
386
387        let members = chain.current_members();
388        assert_eq!(members.len(), 1);
389        assert_eq!(members[0].0, pubkey_hex(&owner));
390    }
391
392    #[test]
393    fn is_member_at_tracks_over_time() {
394        let owner = gen_keypair();
395        let member = gen_keypair();
396
397        let chain = MembershipChain::from_entries(vec![
398            founder_entry(&owner, "0000000001000-0000-dev1"),
399            make_entry(
400                &owner,
401                MembershipAction::Add,
402                &member,
403                MemberRole::Member,
404                "0000000002000-0000-dev1",
405            ),
406            make_entry(
407                &owner,
408                MembershipAction::Remove,
409                &member,
410                MemberRole::Member,
411                "0000000004000-0000-dev1",
412            ),
413        ])
414        .unwrap();
415
416        // Owner is always a member.
417        assert!(chain.is_member_at(&pubkey_hex(&owner), "0000000001000-0000-dev1"));
418        assert!(chain.is_member_at(&pubkey_hex(&owner), "0000000005000-0000-dev1"));
419
420        // Member added at t=2000, removed at t=4000.
421        assert!(!chain.is_member_at(&pubkey_hex(&member), "0000000000500-0000-dev1"));
422        assert!(chain.is_member_at(&pubkey_hex(&member), "0000000002000-0000-dev1"));
423        assert!(chain.is_member_at(&pubkey_hex(&member), "0000000003000-0000-dev1"));
424        assert!(!chain.is_member_at(&pubkey_hex(&member), "0000000004000-0000-dev1"));
425    }
426
427    #[test]
428    fn current_members_returns_active() {
429        let owner = gen_keypair();
430        let m1 = gen_keypair();
431        let m2 = gen_keypair();
432
433        let chain = MembershipChain::from_entries(vec![
434            founder_entry(&owner, "0000000001000-0000-dev1"),
435            make_entry(
436                &owner,
437                MembershipAction::Add,
438                &m1,
439                MemberRole::Member,
440                "0000000002000-0000-dev1",
441            ),
442            make_entry(
443                &owner,
444                MembershipAction::Add,
445                &m2,
446                MemberRole::Member,
447                "0000000003000-0000-dev1",
448            ),
449            make_entry(
450                &owner,
451                MembershipAction::Remove,
452                &m1,
453                MemberRole::Member,
454                "0000000004000-0000-dev1",
455            ),
456        ])
457        .unwrap();
458
459        let members = chain.current_members();
460        assert_eq!(members.len(), 2);
461        assert!(members.iter().any(|(pk, _)| pk == &pubkey_hex(&owner)));
462        assert!(members.iter().any(|(pk, _)| pk == &pubkey_hex(&m2)));
463        assert!(!members.iter().any(|(pk, _)| pk == &pubkey_hex(&m1)));
464    }
465
466    #[test]
467    fn add_signed_by_non_owner_fails() {
468        let owner = gen_keypair();
469        let member = gen_keypair();
470        let outsider = gen_keypair();
471
472        let mut chain = MembershipChain::new();
473        chain
474            .add_entry(founder_entry(&owner, "0000000001000-0000-dev1"))
475            .unwrap();
476        chain
477            .add_entry(make_entry(
478                &owner,
479                MembershipAction::Add,
480                &member,
481                MemberRole::Member,
482                "0000000002000-0000-dev1",
483            ))
484            .unwrap();
485
486        // Member (not owner) tries to add someone.
487        let result = chain.add_entry(make_entry(
488            &member,
489            MembershipAction::Add,
490            &outsider,
491            MemberRole::Member,
492            "0000000003000-0000-dev1",
493        ));
494
495        assert!(matches!(result, Err(MembershipError::NotAnOwner(_))));
496    }
497
498    #[test]
499    fn remove_signed_by_non_owner_fails() {
500        let owner = gen_keypair();
501        let m1 = gen_keypair();
502        let m2 = gen_keypair();
503
504        let mut chain = MembershipChain::new();
505        chain
506            .add_entry(founder_entry(&owner, "0000000001000-0000-dev1"))
507            .unwrap();
508        chain
509            .add_entry(make_entry(
510                &owner,
511                MembershipAction::Add,
512                &m1,
513                MemberRole::Member,
514                "0000000002000-0000-dev1",
515            ))
516            .unwrap();
517        chain
518            .add_entry(make_entry(
519                &owner,
520                MembershipAction::Add,
521                &m2,
522                MemberRole::Member,
523                "0000000003000-0000-dev1",
524            ))
525            .unwrap();
526
527        // m1 (Member, not Owner) tries to remove m2.
528        let result = chain.add_entry(make_entry(
529            &m1,
530            MembershipAction::Remove,
531            &m2,
532            MemberRole::Member,
533            "0000000004000-0000-dev1",
534        ));
535
536        assert!(matches!(result, Err(MembershipError::NotAnOwner(_))));
537    }
538
539    #[test]
540    fn first_entry_not_self_signed_owner_add_fails() {
541        let kp1 = gen_keypair();
542        let kp2 = gen_keypair();
543
544        // First entry signed by kp1 but adding kp2 as owner (not self-signed).
545        let entry = make_entry(
546            &kp1,
547            MembershipAction::Add,
548            &kp2,
549            MemberRole::Owner,
550            "0000000001000-0000-dev1",
551        );
552
553        let mut chain = MembershipChain::new();
554        let result = chain.add_entry(entry);
555        assert!(matches!(result, Err(MembershipError::InvalidFirstEntry)));
556    }
557
558    #[test]
559    fn first_entry_as_member_fails() {
560        let kp = gen_keypair();
561        let pk_hex = pubkey_hex(&kp);
562
563        // Self-signed but role is Member, not Owner.
564        let mut entry = MembershipEntry {
565            action: MembershipAction::Add,
566            user_pubkey: pk_hex.clone(),
567            role: MemberRole::Member,
568            timestamp: "0000000001000-0000-dev1".to_string(),
569            author_pubkey: pk_hex,
570            signature: String::new(),
571        };
572        sign_membership_entry(&mut entry, &kp);
573
574        let mut chain = MembershipChain::new();
575        let result = chain.add_entry(entry);
576        assert!(matches!(result, Err(MembershipError::InvalidFirstEntry)));
577    }
578
579    #[test]
580    fn tampered_entry_fails_signature_verification() {
581        let owner = gen_keypair();
582        let member = gen_keypair();
583
584        let mut entry = make_entry(
585            &owner,
586            MembershipAction::Add,
587            &member,
588            MemberRole::Member,
589            "0000000002000-0000-dev1",
590        );
591
592        // Tamper with the role after signing.
593        entry.role = MemberRole::Owner;
594
595        assert!(!verify_membership_entry(&entry));
596
597        // Also fails when added to a chain.
598        let mut chain = MembershipChain::new();
599        chain
600            .add_entry(founder_entry(&owner, "0000000001000-0000-dev1"))
601            .unwrap();
602
603        let result = chain.add_entry(entry);
604        assert!(matches!(result, Err(MembershipError::InvalidSignature(_))));
605    }
606
607    #[test]
608    fn entry_ordering_by_timestamp() {
609        let owner = gen_keypair();
610        let m1 = gen_keypair();
611        let m2 = gen_keypair();
612
613        // Create entries out of order.
614        let e3 = make_entry(
615            &owner,
616            MembershipAction::Add,
617            &m2,
618            MemberRole::Member,
619            "0000000003000-0000-dev1",
620        );
621        let e1 = founder_entry(&owner, "0000000001000-0000-dev1");
622        let e2 = make_entry(
623            &owner,
624            MembershipAction::Add,
625            &m1,
626            MemberRole::Member,
627            "0000000002000-0000-dev1",
628        );
629
630        // from_entries should sort and validate them.
631        let chain = MembershipChain::from_entries(vec![e3, e1, e2]).unwrap();
632
633        // Verify they're sorted.
634        let entries = chain.entries();
635        assert!(entries[0].timestamp < entries[1].timestamp);
636        assert!(entries[1].timestamp < entries[2].timestamp);
637    }
638
639    #[test]
640    fn validate_empty_chain_fails() {
641        let chain = MembershipChain::new();
642        let result = chain.validate();
643        assert!(matches!(result, Err(MembershipError::EmptyChain)));
644    }
645
646    #[test]
647    fn validate_detects_invalid_signature_in_middle() {
648        let owner = gen_keypair();
649        let member = gen_keypair();
650
651        let e1 = founder_entry(&owner, "0000000001000-0000-dev1");
652        let mut e2 = make_entry(
653            &owner,
654            MembershipAction::Add,
655            &member,
656            MemberRole::Member,
657            "0000000002000-0000-dev1",
658        );
659
660        // Tamper with e2's timestamp after signing.
661        e2.timestamp = "0000000002500-0000-dev1".to_string();
662
663        let result = MembershipChain::from_entries(vec![e1, e2]);
664        assert!(matches!(result, Err(MembershipError::InvalidSignature(1))));
665    }
666
667    #[test]
668    fn canonical_bytes_is_deterministic() {
669        let kp = gen_keypair();
670        let entry = MembershipEntry {
671            action: MembershipAction::Add,
672            user_pubkey: pubkey_hex(&kp),
673            role: MemberRole::Owner,
674            timestamp: "0000000001000-0000-dev1".to_string(),
675            author_pubkey: pubkey_hex(&kp),
676            signature: "does-not-matter".to_string(),
677        };
678
679        let b1 = canonical_bytes(&entry);
680        let b2 = canonical_bytes(&entry);
681        assert_eq!(b1, b2);
682
683        // Signature is not included in canonical bytes.
684        let mut entry2 = entry.clone();
685        entry2.signature = "something-else".to_string();
686        assert_eq!(canonical_bytes(&entry2), b1);
687    }
688}