coven/sync/
invite.rs

1use crate::encryption;
2use crate::keys::{self, KeyError, UserKeypair};
3/// Invitation and revocation flow for shared library membership.
4///
5/// `create_invitation()` is called by the library owner to invite a new member.
6/// `unwrap_library_key()` is called by the invitee to unwrap the library key.
7/// `revoke_member()` is called by the library owner to remove a member and rotate the key.
8use crate::storage::cloud::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
9
10use super::membership::{
11    sign_membership_entry, MemberRole, MembershipAction, MembershipChain, MembershipEntry,
12    MembershipError,
13};
14use super::storage::{StorageError, SyncStorage};
15
16#[derive(Debug, thiserror::Error)]
17pub enum InviteError {
18    #[error("Bucket error: {0}")]
19    Bucket(#[from] StorageError),
20    #[error("Key error: {0}")]
21    Key(#[from] KeyError),
22    #[error("Membership error: {0}")]
23    Membership(#[from] MembershipError),
24    #[error("Cloud home error: {0}")]
25    CloudHome(#[from] CloudHomeError),
26    #[error("Crypto error: {0}")]
27    Crypto(String),
28    #[error("User {0} is not a current member")]
29    NotAMember(String),
30    #[error("Cannot revoke the last owner of a library")]
31    LastOwner,
32}
33
34/// Determine the next seq for an author's membership entries in the storage.
35async fn next_membership_seq(
36    storage: &dyn SyncStorage,
37    author_pubkey_hex: &str,
38) -> Result<u64, InviteError> {
39    let existing_entries = storage.list_membership_entries().await?;
40    Ok(existing_entries
41        .iter()
42        .filter(|(author, _)| author == author_pubkey_hex)
43        .map(|(_, seq)| seq)
44        .max()
45        .map_or(1, |max| max + 1))
46}
47
48/// Decode and convert an Ed25519 hex pubkey to X25519 for sealed box encryption.
49fn ed25519_hex_to_x25519(
50    ed25519_pubkey_hex: &str,
51) -> Result<[u8; keys::CURVE25519_PUBLICKEYBYTES], InviteError> {
52    let pk_bytes: [u8; keys::SIGN_PUBLICKEYBYTES] = hex::decode(ed25519_pubkey_hex)
53        .map_err(|e| InviteError::Crypto(format!("invalid pubkey hex: {e}")))?
54        .try_into()
55        .map_err(|_| InviteError::Crypto("pubkey wrong length".to_string()))?;
56    Ok(keys::ed25519_to_x25519_public_key(&pk_bytes))
57}
58
59/// Upload a signed membership entry to the storage.
60async fn upload_membership_entry(
61    storage: &dyn SyncStorage,
62    entry: &MembershipEntry,
63    author_pubkey_hex: &str,
64) -> Result<(), InviteError> {
65    let next_seq = next_membership_seq(storage, author_pubkey_hex).await?;
66
67    let entry_bytes =
68        serde_json::to_vec(entry).map_err(|e| InviteError::Crypto(format!("serialize: {e}")))?;
69    storage
70        .put_membership_entry(author_pubkey_hex, next_seq, entry_bytes)
71        .await?;
72
73    Ok(())
74}
75
76/// Create an invitation for a new member.
77///
78/// This grants access on the cloud home, wraps the library encryption key
79/// to the invitee's X25519 public key, creates and signs a membership entry
80/// (Add), validates it against the local chain, and uploads both to the storage.
81/// Returns the JoinInfo so the caller can share connection details with the invitee.
82pub async fn create_invitation(
83    storage: &dyn SyncStorage,
84    cloud_home: &dyn CloudHome,
85    chain: &mut MembershipChain,
86    owner_keypair: &UserKeypair,
87    invitee_ed25519_pubkey: &str,
88    role: MemberRole,
89    encryption_key: &[u8; 32],
90    timestamp: &str,
91) -> Result<CloudHomeJoinInfo, InviteError> {
92    // Grant access on the cloud home (no-op for S3, shares folder for consumer clouds).
93    let join_info = cloud_home.grant_access(invitee_ed25519_pubkey).await?;
94
95    // Convert Ed25519 -> X25519 for sealed box encryption.
96    let invitee_x25519_pk = ed25519_hex_to_x25519(invitee_ed25519_pubkey)?;
97
98    // Wrap the library encryption key.
99    let wrapped_key = keys::seal_box_encrypt(encryption_key, &invitee_x25519_pk);
100
101    // Create and sign a membership entry.
102    let mut entry = MembershipEntry {
103        action: MembershipAction::Add,
104        user_pubkey: invitee_ed25519_pubkey.to_string(),
105        role,
106        timestamp: timestamp.to_string(),
107        author_pubkey: String::new(),
108        signature: String::new(),
109    };
110    sign_membership_entry(&mut entry, owner_keypair);
111
112    // Validate against the local chain BEFORE any storage writes.
113    chain.add_entry(entry.clone())?;
114
115    // Upload wrapped key and membership entry.
116    storage
117        .put_wrapped_key(invitee_ed25519_pubkey, wrapped_key)
118        .await?;
119
120    let author_pubkey_hex = hex::encode(owner_keypair.public_key);
121    upload_membership_entry(storage, &entry, &author_pubkey_hex).await?;
122
123    Ok(join_info)
124}
125
126/// Accept an invitation by downloading and unwrapping the library encryption key.
127///
128/// The invitee calls this after receiving an invitation. It downloads the
129/// wrapped key from cloud storage and decrypts it with the invitee's X25519 keys.
130pub async fn unwrap_library_key(
131    cloud_home: &dyn CloudHome,
132    keypair: &UserKeypair,
133) -> Result<[u8; 32], InviteError> {
134    let pubkey_hex = hex::encode(keypair.public_key);
135
136    // Download wrapped key.
137    let key_path = format!("keys/{pubkey_hex}.enc");
138    let wrapped_key = cloud_home.read(&key_path).await?;
139
140    // Decrypt with our X25519 keys.
141    let x25519_pk = keypair.to_x25519_public_key();
142    let x25519_sk = keypair.to_x25519_secret_key();
143
144    let plaintext = keys::seal_box_decrypt(&wrapped_key, &x25519_pk, &x25519_sk)?;
145
146    let encryption_key: [u8; 32] = plaintext
147        .try_into()
148        .map_err(|_| InviteError::Crypto("unwrapped key is not 32 bytes".to_string()))?;
149
150    Ok(encryption_key)
151}
152
153/// Revoke a member from the library. This:
154/// 1. Revokes access on the cloud home
155/// 2. Creates a Remove membership entry signed by the owner
156/// 3. Generates a new library encryption key
157/// 4. Re-wraps the new key to all remaining members
158/// 5. Deletes the revoked member's wrapped key
159/// 6. Uploads updated entries and keys
160///
161/// Returns the new encryption key (caller must persist it and start using it).
162pub async fn revoke_member(
163    storage: &dyn SyncStorage,
164    cloud_home: &dyn CloudHome,
165    chain: &mut MembershipChain,
166    owner_keypair: &UserKeypair,
167    revokee_pubkey: &str,
168    timestamp: &str,
169) -> Result<[u8; 32], InviteError> {
170    let members = chain.current_members();
171
172    // Verify the revokee is a current member.
173    if !members.iter().any(|(pk, _)| pk == revokee_pubkey) {
174        return Err(InviteError::NotAMember(revokee_pubkey.to_string()));
175    }
176
177    // Ensure at least one owner would remain after the removal.
178    let remaining_owners = members
179        .iter()
180        .filter(|(pk, role)| pk != revokee_pubkey && *role == MemberRole::Owner)
181        .count();
182    if remaining_owners == 0 {
183        return Err(InviteError::LastOwner);
184    }
185
186    // Revoke access on the cloud home (no-op for S3, removes share for consumer clouds).
187    cloud_home.revoke_access(revokee_pubkey).await?;
188
189    // Create and sign a Remove entry.
190    let mut entry = MembershipEntry {
191        action: MembershipAction::Remove,
192        user_pubkey: revokee_pubkey.to_string(),
193        role: MemberRole::Member, // role field is not meaningful for Remove, but required
194        timestamp: timestamp.to_string(),
195        author_pubkey: String::new(),
196        signature: String::new(),
197    };
198    sign_membership_entry(&mut entry, owner_keypair);
199
200    // Validate against the local chain BEFORE any storage writes.
201    chain.add_entry(entry.clone())?;
202
203    // Upload the Remove entry.
204    let author_pubkey_hex = hex::encode(owner_keypair.public_key);
205    upload_membership_entry(storage, &entry, &author_pubkey_hex).await?;
206
207    // Generate a new random encryption key.
208    let new_key = encryption::generate_random_key();
209
210    // Re-wrap the new key to all remaining members.
211    let remaining_members = chain.current_members();
212    for (member_pubkey, _) in &remaining_members {
213        let x25519_pk = ed25519_hex_to_x25519(member_pubkey)?;
214        let wrapped = keys::seal_box_encrypt(&new_key, &x25519_pk);
215        storage.put_wrapped_key(member_pubkey, wrapped).await?;
216    }
217
218    // Delete the revoked member's wrapped key.
219    storage.delete_wrapped_key(revokee_pubkey).await?;
220
221    Ok(new_key)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::storage::cloud::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
228    use crate::sync::membership::MemberRole;
229    use crate::sync::test_helpers::MockSyncStorage;
230    use async_trait::async_trait;
231
232    /// Minimal CloudHome mock that returns a dummy S3 JoinInfo.
233    struct MockCloudHome;
234
235    #[async_trait]
236    impl CloudHome for MockCloudHome {
237        async fn write(&self, _key: &str, _data: Vec<u8>) -> Result<(), CloudHomeError> {
238            Ok(())
239        }
240        async fn read(&self, _key: &str) -> Result<Vec<u8>, CloudHomeError> {
241            Err(CloudHomeError::NotFound("mock".to_string()))
242        }
243        async fn read_range(
244            &self,
245            _key: &str,
246            _start: u64,
247            _end: u64,
248        ) -> Result<Vec<u8>, CloudHomeError> {
249            Err(CloudHomeError::NotFound("mock".to_string()))
250        }
251        async fn list(&self, _prefix: &str) -> Result<Vec<String>, CloudHomeError> {
252            Ok(vec![])
253        }
254        async fn delete(&self, _key: &str) -> Result<(), CloudHomeError> {
255            Ok(())
256        }
257        async fn exists(&self, _key: &str) -> Result<bool, CloudHomeError> {
258            Ok(false)
259        }
260        async fn grant_access(
261            &self,
262            _member_id: &str,
263        ) -> Result<CloudHomeJoinInfo, CloudHomeError> {
264            Ok(CloudHomeJoinInfo::S3 {
265                bucket: "test-bucket".to_string(),
266                region: "us-east-1".to_string(),
267                endpoint: None,
268                access_key: "test-access-key".to_string(),
269                secret_key: "test-secret-key".to_string(),
270                key_prefix: None,
271            })
272        }
273        async fn revoke_access(&self, _member_id: &str) -> Result<(), CloudHomeError> {
274            Ok(())
275        }
276    }
277
278    fn gen_keypair() -> UserKeypair {
279        UserKeypair::generate()
280    }
281
282    fn pubkey_hex(kp: &UserKeypair) -> String {
283        hex::encode(kp.public_key)
284    }
285
286    /// Bootstrap a chain with a founder entry.
287    fn bootstrap_chain(owner: &UserKeypair) -> MembershipChain {
288        let pk_hex = pubkey_hex(owner);
289        let mut entry = MembershipEntry {
290            action: MembershipAction::Add,
291            user_pubkey: pk_hex.clone(),
292            role: MemberRole::Owner,
293            timestamp: "0000000001000-0000-dev1".to_string(),
294            author_pubkey: pk_hex,
295            signature: String::new(),
296        };
297        sign_membership_entry(&mut entry, owner);
298
299        let mut chain = MembershipChain::new();
300        chain.add_entry(entry).unwrap();
301        chain
302    }
303
304    #[tokio::test]
305    async fn create_and_unwrap_library_key() {
306        let owner = gen_keypair();
307        let invitee = gen_keypair();
308        let encryption_key: [u8; 32] = [42u8; 32];
309
310        let storage = MockSyncStorage::new();
311        let mut chain = bootstrap_chain(&owner);
312
313        // Owner invites the new member.
314        create_invitation(
315            &storage,
316            &MockCloudHome,
317            &mut chain,
318            &owner,
319            &pubkey_hex(&invitee),
320            MemberRole::Member,
321            &encryption_key,
322            "0000000002000-0000-dev1",
323        )
324        .await
325        .unwrap();
326
327        // Chain should now have 2 entries.
328        assert_eq!(chain.entries().len(), 2);
329        chain.validate().unwrap();
330
331        // Invitee should be a current member.
332        let members = chain.current_members();
333        assert!(members
334            .iter()
335            .any(|(pk, r)| pk == &pubkey_hex(&invitee) && *r == MemberRole::Member));
336
337        // Invitee accepts the invitation.
338        let unwrapped = unwrap_library_key(&storage as &dyn CloudHome, &invitee)
339            .await
340            .unwrap();
341        assert_eq!(unwrapped, encryption_key);
342    }
343
344    #[tokio::test]
345    async fn unwrap_library_key_wrong_key_fails() {
346        let owner = gen_keypair();
347        let invitee = gen_keypair();
348        let wrong_keypair = gen_keypair();
349        let encryption_key: [u8; 32] = [7u8; 32];
350
351        let storage = MockSyncStorage::new();
352        let mut chain = bootstrap_chain(&owner);
353
354        create_invitation(
355            &storage,
356            &MockCloudHome,
357            &mut chain,
358            &owner,
359            &pubkey_hex(&invitee),
360            MemberRole::Member,
361            &encryption_key,
362            "0000000002000-0000-dev1",
363        )
364        .await
365        .unwrap();
366
367        // Someone else tries to accept -- should fail.
368        let result = unwrap_library_key(&storage as &dyn CloudHome, &wrong_keypair).await;
369        assert!(result.is_err());
370    }
371
372    #[tokio::test]
373    async fn create_invitation_invalid_pubkey_hex() {
374        let owner = gen_keypair();
375        let storage = MockSyncStorage::new();
376        let mut chain = bootstrap_chain(&owner);
377        let encryption_key: [u8; 32] = [0u8; 32];
378
379        let result = create_invitation(
380            &storage,
381            &MockCloudHome,
382            &mut chain,
383            &owner,
384            "not-valid-hex",
385            MemberRole::Member,
386            &encryption_key,
387            "0000000002000-0000-dev1",
388        )
389        .await;
390
391        assert!(matches!(result, Err(InviteError::Crypto(_))));
392    }
393
394    #[tokio::test]
395    async fn create_invitation_non_owner_fails() {
396        let owner = gen_keypair();
397        let member = gen_keypair();
398        let invitee = gen_keypair();
399        let encryption_key: [u8; 32] = [0u8; 32];
400
401        let storage = MockSyncStorage::new();
402        let mut chain = bootstrap_chain(&owner);
403
404        // Add member first.
405        create_invitation(
406            &storage,
407            &MockCloudHome,
408            &mut chain,
409            &owner,
410            &pubkey_hex(&member),
411            MemberRole::Member,
412            &encryption_key,
413            "0000000002000-0000-dev1",
414        )
415        .await
416        .unwrap();
417
418        // Member (not owner) tries to invite someone.
419        let result = create_invitation(
420            &storage,
421            &MockCloudHome,
422            &mut chain,
423            &member,
424            &pubkey_hex(&invitee),
425            MemberRole::Member,
426            &encryption_key,
427            "0000000003000-0000-dev1",
428        )
429        .await;
430
431        assert!(matches!(result, Err(InviteError::Membership(_))));
432    }
433
434    #[tokio::test]
435    async fn membership_entry_uploaded_to_bucket() {
436        let owner = gen_keypair();
437        let invitee = gen_keypair();
438        let encryption_key: [u8; 32] = [1u8; 32];
439
440        let storage = MockSyncStorage::new();
441        let mut chain = bootstrap_chain(&owner);
442
443        create_invitation(
444            &storage,
445            &MockCloudHome,
446            &mut chain,
447            &owner,
448            &pubkey_hex(&invitee),
449            MemberRole::Member,
450            &encryption_key,
451            "0000000002000-0000-dev1",
452        )
453        .await
454        .unwrap();
455
456        // Verify the membership entry was uploaded.
457        let entries = storage.list_membership_entries().await.unwrap();
458        let owner_entries: Vec<_> = entries
459            .iter()
460            .filter(|(author, _)| author == &pubkey_hex(&owner))
461            .collect();
462        assert_eq!(owner_entries.len(), 1);
463
464        // Verify the wrapped key was uploaded.
465        let wrapped = storage
466            .get_wrapped_key(&pubkey_hex(&invitee))
467            .await
468            .unwrap();
469        assert!(!wrapped.is_empty());
470    }
471
472    #[tokio::test]
473    async fn revoke_member_roundtrip() {
474        let owner = gen_keypair();
475        let member = gen_keypair();
476        let old_key: [u8; 32] = [42u8; 32];
477
478        let storage = MockSyncStorage::new();
479        let mut chain = bootstrap_chain(&owner);
480
481        // Owner invites the member.
482        create_invitation(
483            &storage,
484            &MockCloudHome,
485            &mut chain,
486            &owner,
487            &pubkey_hex(&member),
488            MemberRole::Member,
489            &old_key,
490            "0000000002000-0000-dev1",
491        )
492        .await
493        .unwrap();
494
495        // Member can unwrap the key.
496        let unwrapped = unwrap_library_key(&storage as &dyn CloudHome, &member)
497            .await
498            .unwrap();
499        assert_eq!(unwrapped, old_key);
500
501        // Owner revokes the member.
502        let new_key = revoke_member(
503            &storage,
504            &MockCloudHome,
505            &mut chain,
506            &owner,
507            &pubkey_hex(&member),
508            "0000000003000-0000-dev1",
509        )
510        .await
511        .unwrap();
512
513        // New key should be different from old key.
514        assert_ne!(new_key, old_key);
515
516        // Member is no longer in the chain.
517        let members = chain.current_members();
518        assert!(!members.iter().any(|(pk, _)| pk == &pubkey_hex(&member)));
519        assert!(members.iter().any(|(pk, _)| pk == &pubkey_hex(&owner)));
520
521        // Chain should still validate.
522        chain.validate().unwrap();
523
524        // Revoked member's wrapped key was deleted from the storage.
525        let result = storage.get_wrapped_key(&pubkey_hex(&member)).await;
526        assert!(result.is_err());
527
528        // Owner can still unwrap the new key.
529        let owner_unwrapped = unwrap_library_key(&storage as &dyn CloudHome, &owner)
530            .await
531            .unwrap();
532        assert_eq!(owner_unwrapped, new_key);
533
534        // The Remove entry was uploaded to the storage.
535        let entries = storage.list_membership_entries().await.unwrap();
536        let owner_entries: Vec<_> = entries
537            .iter()
538            .filter(|(author, _)| author == &pubkey_hex(&owner))
539            .collect();
540        // 1 for invite + 1 for revoke = 2
541        assert_eq!(owner_entries.len(), 2);
542    }
543
544    #[tokio::test]
545    async fn revoke_member_with_multiple_remaining() {
546        let owner = gen_keypair();
547        let member1 = gen_keypair();
548        let member2 = gen_keypair();
549        let old_key: [u8; 32] = [10u8; 32];
550
551        let storage = MockSyncStorage::new();
552        let mut chain = bootstrap_chain(&owner);
553
554        // Invite two members.
555        create_invitation(
556            &storage,
557            &MockCloudHome,
558            &mut chain,
559            &owner,
560            &pubkey_hex(&member1),
561            MemberRole::Member,
562            &old_key,
563            "0000000002000-0000-dev1",
564        )
565        .await
566        .unwrap();
567
568        create_invitation(
569            &storage,
570            &MockCloudHome,
571            &mut chain,
572            &owner,
573            &pubkey_hex(&member2),
574            MemberRole::Member,
575            &old_key,
576            "0000000003000-0000-dev1",
577        )
578        .await
579        .unwrap();
580
581        // Revoke member1.
582        let new_key = revoke_member(
583            &storage,
584            &MockCloudHome,
585            &mut chain,
586            &owner,
587            &pubkey_hex(&member1),
588            "0000000004000-0000-dev1",
589        )
590        .await
591        .unwrap();
592
593        // Both remaining members (owner + member2) can unwrap the new key.
594        let owner_key = unwrap_library_key(&storage as &dyn CloudHome, &owner)
595            .await
596            .unwrap();
597        assert_eq!(owner_key, new_key);
598
599        let member2_key = unwrap_library_key(&storage as &dyn CloudHome, &member2)
600            .await
601            .unwrap();
602        assert_eq!(member2_key, new_key);
603
604        // member1 cannot get a wrapped key.
605        let result = storage.get_wrapped_key(&pubkey_hex(&member1)).await;
606        assert!(result.is_err());
607    }
608
609    #[tokio::test]
610    async fn revoke_non_member_fails() {
611        let owner = gen_keypair();
612        let outsider = gen_keypair();
613
614        let storage = MockSyncStorage::new();
615        let mut chain = bootstrap_chain(&owner);
616
617        let result = revoke_member(
618            &storage,
619            &MockCloudHome,
620            &mut chain,
621            &owner,
622            &pubkey_hex(&outsider),
623            "0000000002000-0000-dev1",
624        )
625        .await;
626
627        assert!(matches!(result, Err(InviteError::NotAMember(_))));
628    }
629
630    #[tokio::test]
631    async fn revoke_last_owner_fails() {
632        let owner = gen_keypair();
633        let member = gen_keypair();
634
635        let storage = MockSyncStorage::new();
636        let mut chain = bootstrap_chain(&owner);
637
638        // Add a regular member.
639        create_invitation(
640            &storage,
641            &MockCloudHome,
642            &mut chain,
643            &owner,
644            &pubkey_hex(&member),
645            MemberRole::Member,
646            &[42u8; 32],
647            "0000000002000-0000-dev1",
648        )
649        .await
650        .unwrap();
651
652        // Owner tries to revoke themselves (the only owner).
653        let result = revoke_member(
654            &storage,
655            &MockCloudHome,
656            &mut chain,
657            &owner,
658            &pubkey_hex(&owner),
659            "0000000003000-0000-dev1",
660        )
661        .await;
662
663        assert!(matches!(result, Err(InviteError::LastOwner)));
664    }
665
666    #[tokio::test]
667    async fn non_owner_revoke_fails() {
668        let owner = gen_keypair();
669        let member1 = gen_keypair();
670        let member2 = gen_keypair();
671
672        let storage = MockSyncStorage::new();
673        let mut chain = bootstrap_chain(&owner);
674
675        // Add two members.
676        create_invitation(
677            &storage,
678            &MockCloudHome,
679            &mut chain,
680            &owner,
681            &pubkey_hex(&member1),
682            MemberRole::Member,
683            &[42u8; 32],
684            "0000000002000-0000-dev1",
685        )
686        .await
687        .unwrap();
688
689        create_invitation(
690            &storage,
691            &MockCloudHome,
692            &mut chain,
693            &owner,
694            &pubkey_hex(&member2),
695            MemberRole::Member,
696            &[42u8; 32],
697            "0000000003000-0000-dev1",
698        )
699        .await
700        .unwrap();
701
702        // Member (not owner) tries to revoke another member.
703        let result = revoke_member(
704            &storage,
705            &MockCloudHome,
706            &mut chain,
707            &member1,
708            &pubkey_hex(&member2),
709            "0000000004000-0000-dev1",
710        )
711        .await;
712
713        assert!(matches!(result, Err(InviteError::Membership(_))));
714    }
715}