1use crate::encryption;
2use crate::keys::{self, KeyError, UserKeypair};
3use 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
34async 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
48fn 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
59async 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
76pub 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 let join_info = cloud_home.grant_access(invitee_ed25519_pubkey).await?;
94
95 let invitee_x25519_pk = ed25519_hex_to_x25519(invitee_ed25519_pubkey)?;
97
98 let wrapped_key = keys::seal_box_encrypt(encryption_key, &invitee_x25519_pk);
100
101 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 chain.add_entry(entry.clone())?;
114
115 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
126pub 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 let key_path = format!("keys/{pubkey_hex}.enc");
138 let wrapped_key = cloud_home.read(&key_path).await?;
139
140 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
153pub 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 if !members.iter().any(|(pk, _)| pk == revokee_pubkey) {
174 return Err(InviteError::NotAMember(revokee_pubkey.to_string()));
175 }
176
177 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 cloud_home.revoke_access(revokee_pubkey).await?;
188
189 let mut entry = MembershipEntry {
191 action: MembershipAction::Remove,
192 user_pubkey: revokee_pubkey.to_string(),
193 role: MemberRole::Member, timestamp: timestamp.to_string(),
195 author_pubkey: String::new(),
196 signature: String::new(),
197 };
198 sign_membership_entry(&mut entry, owner_keypair);
199
200 chain.add_entry(entry.clone())?;
202
203 let author_pubkey_hex = hex::encode(owner_keypair.public_key);
205 upload_membership_entry(storage, &entry, &author_pubkey_hex).await?;
206
207 let new_key = encryption::generate_random_key();
209
210 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 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 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 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 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 assert_eq!(chain.entries().len(), 2);
329 chain.validate().unwrap();
330
331 let members = chain.current_members();
333 assert!(members
334 .iter()
335 .any(|(pk, r)| pk == &pubkey_hex(&invitee) && *r == MemberRole::Member));
336
337 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 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 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 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 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 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 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 let unwrapped = unwrap_library_key(&storage as &dyn CloudHome, &member)
497 .await
498 .unwrap();
499 assert_eq!(unwrapped, old_key);
500
501 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 assert_ne!(new_key, old_key);
515
516 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.validate().unwrap();
523
524 let result = storage.get_wrapped_key(&pubkey_hex(&member)).await;
526 assert!(result.is_err());
527
528 let owner_unwrapped = unwrap_library_key(&storage as &dyn CloudHome, &owner)
530 .await
531 .unwrap();
532 assert_eq!(owner_unwrapped, new_key);
533
534 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 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 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 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 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 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 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 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 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 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}