1use serde::{Deserialize, Serialize};
2
3use crate::code_envelope::{self, EnvelopeError};
4use crate::storage::cloud::CloudHomeJoinInfo;
5use crate::sync::membership::MembershipHeadRef;
6#[cfg(test)]
7use crate::sync::membership::{MembershipCoord, MembershipGrantId};
8#[cfg(test)]
9use crate::sync::store_commit::ObjectHash;
10
11pub const INVITE_CODE_VERSION: u8 = 3;
12
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
14#[serde(rename_all = "snake_case", deny_unknown_fields)]
15pub enum MembershipFloor {
16 MergeConcurrent(Vec<MembershipHeadRef>),
17 Serial(Option<crate::sync::store_commit::StoreBatchCommitRef>),
18}
19
20impl MembershipFloor {
21 pub fn write_policy(&self) -> crate::WritePolicy {
22 match self {
23 Self::MergeConcurrent(_) => crate::WritePolicy::MergeConcurrent,
24 Self::Serial(_) => crate::WritePolicy::Serial,
25 }
26 }
27}
28
29#[derive(Serialize, Deserialize, Debug)]
37pub struct InviteCode {
38 pub v: u8,
40 pub store_id: String,
41 pub store_name: String,
42 pub join_info: CloudHomeJoinInfo,
43 pub owner_pubkey: String,
44 pub key_author_pubkey: String,
45 pub store_root: crate::sync::store_commit::StoreRootRef,
46 pub membership_floor: MembershipFloor,
50}
51
52pub fn encode(code: &InviteCode) -> String {
54 code_envelope::encode_code(code_envelope::PREFIX, code)
55}
56
57pub fn decode(s: &str) -> Result<InviteCode, JoinCodeError> {
59 let code: InviteCode = code_envelope::decode_code(code_envelope::PREFIX, s)?;
60 if code.v != INVITE_CODE_VERSION {
61 return Err(JoinCodeError::UnsupportedVersion(code.v));
62 }
63 crate::store_dir::validate_path_token(&code.store_id).map_err(JoinCodeError::InvalidStoreId)?;
70 crate::sync::restore_code::decode_hex_bytes("owner public key", &code.owner_pubkey, 32)
75 .map_err(JoinCodeError::InvalidOwnerPubkey)?;
76 crate::sync::restore_code::decode_hex_bytes(
77 "wrapped-key author public key",
78 &code.key_author_pubkey,
79 32,
80 )
81 .map_err(JoinCodeError::InvalidOwnerPubkey)?;
82 match &code.membership_floor {
83 MembershipFloor::MergeConcurrent(floor) => {
84 if floor.is_empty() {
85 return Err(JoinCodeError::EmptyMembershipFloor);
86 }
87 crate::sync::membership_ops::validate_membership_floor(floor)
88 .map_err(JoinCodeError::InvalidMembershipFloor)?;
89 }
90 MembershipFloor::Serial(None) => {
91 return Err(JoinCodeError::EmptyMembershipFloor);
92 }
93 MembershipFloor::Serial(Some(reference))
94 if reference.coord.policy() != crate::WritePolicy::Serial
95 || reference.coord.sequence() == 0 =>
96 {
97 return Err(JoinCodeError::InvalidMembershipFloor(
98 "Serial membership floor is not an exact nonzero Serial commit reference"
99 .to_string(),
100 ));
101 }
102 MembershipFloor::Serial(Some(_)) => {}
103 }
104 Ok(code)
105}
106
107#[derive(Serialize, Deserialize, Debug)]
109pub struct JoinRequestCode {
110 pub public_key: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub email: Option<String>,
113}
114
115pub fn generate_join_request_for_keypair(
118 keypair: &crate::keys::UserKeypair,
119 email: Option<String>,
120) -> String {
121 let code = JoinRequestCode {
122 public_key: hex::encode(keypair.public_key()),
123 email,
124 };
125
126 encode_join_request(&code)
127}
128
129pub fn encode_join_request(code: &JoinRequestCode) -> String {
133 code_envelope::encode_code("", code)
134}
135
136pub fn decode_join_request(s: &str) -> Result<JoinRequestCode, JoinCodeError> {
137 Ok(code_envelope::decode_code("", s)?)
138}
139
140pub struct InviteCodeInfo {
142 pub store_id: String,
143 pub store_name: String,
144 pub owner_pubkey: String,
145 pub store_root_hash: crate::sync::store_commit::ObjectHash,
146 pub cloud_provider: crate::config::CloudProvider,
147 pub needs_oauth: bool,
150}
151
152pub fn decode_invite_code_info(code: &str) -> Result<InviteCodeInfo, JoinCodeError> {
154 let invite = decode(code)?;
155 let cloud_provider = invite.join_info.cloud_provider();
156 Ok(InviteCodeInfo {
157 store_id: invite.store_id,
158 store_name: invite.store_name,
159 owner_pubkey: invite.owner_pubkey,
160 store_root_hash: invite.store_root.store_root_hash,
161 needs_oauth: cloud_provider.needs_oauth(),
162 cloud_provider,
163 })
164}
165
166#[derive(Debug, thiserror::Error)]
167pub enum JoinCodeError {
168 #[error("That doesn't look like a coven invite code — it should start with \"coven:\".")]
169 MissingPrefix,
170 #[error("The invite code is incomplete or has a typo. Check that you copied the entire code.")]
171 InvalidBase64,
172 #[error("The invite code is corrupted. Ask the inviter to generate a new one. ({0})")]
173 InvalidJson(String),
174 #[error("This invite code uses unsupported format version v{0}. Ask the inviter to generate a new one.")]
175 UnsupportedVersion(u8),
176 #[error(
181 "The store id in this invite code is invalid. Ask the inviter to generate a new one. ({0})"
182 )]
183 InvalidStoreId(crate::store_dir::PathTokenError),
184 #[error(
187 "The owner key in this invite code is invalid. Ask the inviter to generate a new one. ({0})"
188 )]
189 InvalidOwnerPubkey(String),
190 #[error("The invite code has no membership floor. Ask the inviter to generate a new one.")]
191 EmptyMembershipFloor,
192 #[error("The membership floor in this invite code is invalid. Ask the inviter to generate a new one. ({0})")]
193 InvalidMembershipFloor(String),
194}
195
196impl From<EnvelopeError> for JoinCodeError {
197 fn from(e: EnvelopeError) -> Self {
198 match e {
199 EnvelopeError::MissingPrefix => JoinCodeError::MissingPrefix,
200 EnvelopeError::InvalidBase64 => JoinCodeError::InvalidBase64,
201 EnvelopeError::InvalidJson(s) => JoinCodeError::InvalidJson(s),
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
210 use base64::Engine;
211
212 fn test_owner_pubkey() -> String {
213 hex::encode([0xAB_u8; 32])
214 }
215
216 fn test_membership_floor() -> Vec<MembershipHeadRef> {
217 let coord = MembershipCoord {
218 author_pubkey: test_owner_pubkey(),
219 author_owner_grant: MembershipGrantId(ObjectHash::digest(b"test owner grant")),
220 stream_id: crate::sync::membership::AuthorStreamId::from_bytes([1; 32]),
221 seq: 1,
222 entry_hash: ObjectHash::digest(b"test membership entry"),
223 };
224 let stored = b"test membership head";
225 vec![MembershipHeadRef {
226 coord,
227 head_hash: ObjectHash::digest(b"test membership head semantic bytes"),
228 object: crate::sync::storage::ExactObjectRef::new(
229 crate::storage::cloud::ObjectSlot::logical(
230 "store-v1/membership/heads/test-owner/1.json".to_string(),
231 )
232 .expect("valid test membership-head slot"),
233 stored.len() as u64,
234 ObjectHash::digest(stored),
235 ),
236 }]
237 }
238
239 fn sample_s3_code(store_id: &str) -> InviteCode {
240 InviteCode {
241 v: INVITE_CODE_VERSION,
242 store_id: store_id.to_string(),
243 store_name: "My Store".into(),
244 join_info: CloudHomeJoinInfo::S3 {
245 bucket: "my-bucket".into(),
246 region: "us-east-1".into(),
247 endpoint: None,
248 access_key: "AKIAEXAMPLE".into(),
249 secret_key: "secret123".into(),
250 key_prefix: None,
251 },
252 owner_pubkey: test_owner_pubkey(),
253 key_author_pubkey: test_owner_pubkey(),
254 store_root: crate::sync::store_commit::StoreRootRef {
255 store_root_id: crate::sync::store_commit::ObjectHash::digest(
256 b"invite store protocol root",
257 ),
258 store_root_hash: ObjectHash::digest(b"root"),
259 object: crate::sync::storage::ExactObjectRef::new(
260 crate::storage::cloud::ObjectSlot::logical(
261 "store-v1/protocol/root/test.json".to_string(),
262 )
263 .expect("valid test Store-root slot"),
264 4,
265 ObjectHash::digest(b"root"),
266 ),
267 },
268 membership_floor: MembershipFloor::MergeConcurrent(test_membership_floor()),
269 }
270 }
271
272 fn test_serial_commit_ref() -> crate::sync::store_commit::StoreBatchCommitRef {
273 let stored = b"invite Serial floor commit";
274 let commit_hash = ObjectHash::digest(b"invite Serial floor semantic bytes");
275 let family = crate::sync::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
276 b"invite Serial floor candidate family",
277 ));
278 crate::sync::store_commit::StoreBatchCommitRef {
279 coord: crate::sync::store_commit::StoreCommitCoord::Serial { sequence: 7 },
280 commit_hash,
281 object: crate::sync::storage::ExactObjectRef::new(
282 crate::storage::cloud::ObjectSlot::logical(format!(
283 "{}.json",
284 crate::sync::store_commit::commit_semantic_prefix(
285 family,
286 crate::sync::store_commit::SERIAL_STREAM_ID,
287 7,
288 commit_hash,
289 )
290 ))
291 .expect("valid test Store-commit slot"),
292 stored.len() as u64,
293 ObjectHash::digest(stored),
294 ),
295 }
296 }
297
298 #[test]
299 fn round_trip_s3() {
300 let code = sample_s3_code("lib-123");
301 let encoded = encode(&code);
302 assert!(encoded.starts_with(code_envelope::PREFIX));
303 let decoded = decode(&encoded).unwrap();
304 assert_eq!(decoded.v, INVITE_CODE_VERSION);
305 assert_eq!(decoded.store_id, "lib-123");
306 assert_eq!(decoded.store_name, "My Store");
307 assert_eq!(decoded.owner_pubkey, test_owner_pubkey());
308 assert_eq!(decoded.store_root, code.store_root);
309 assert_eq!(
310 decoded.membership_floor,
311 MembershipFloor::MergeConcurrent(test_membership_floor())
312 );
313 match decoded.join_info {
314 CloudHomeJoinInfo::S3 {
315 bucket,
316 region,
317 endpoint,
318 access_key,
319 secret_key,
320 key_prefix,
321 } => {
322 assert_eq!(bucket, "my-bucket");
323 assert_eq!(region, "us-east-1");
324 assert_eq!(endpoint, None);
325 assert_eq!(access_key, "AKIAEXAMPLE");
326 assert_eq!(secret_key, "secret123");
327 assert_eq!(key_prefix, None);
328 }
329 _ => panic!("expected S3 variant"),
330 }
331 }
332
333 #[test]
334 fn serial_invite_round_trips_the_exact_commit_floor() {
335 let mut code = sample_s3_code("serial-store");
336 let reference = test_serial_commit_ref();
337 code.membership_floor = MembershipFloor::Serial(Some(reference.clone()));
338 let decoded = decode(&encode(&code)).unwrap();
339 assert_eq!(
340 decoded.membership_floor,
341 MembershipFloor::Serial(Some(reference))
342 );
343 }
344
345 #[test]
346 fn round_trip_s3_with_endpoint_and_key_prefix() {
347 let mut code = sample_s3_code("lib-456");
348 code.join_info = CloudHomeJoinInfo::S3 {
349 bucket: "bucket".into(),
350 region: "eu-west-1".into(),
351 endpoint: Some("https://s3.example.com".into()),
352 access_key: "ak".into(),
353 secret_key: "sk".into(),
354 key_prefix: Some("prefix/".into()),
355 };
356 let encoded = encode(&code);
357 let decoded = decode(&encoded).unwrap();
358 assert_eq!(decoded.store_id, "lib-456");
359 match decoded.join_info {
360 CloudHomeJoinInfo::S3 {
361 endpoint,
362 key_prefix,
363 ..
364 } => {
365 assert_eq!(endpoint, Some("https://s3.example.com".to_string()));
366 assert_eq!(key_prefix, Some("prefix/".to_string()));
367 }
368 _ => panic!("expected S3 variant"),
369 }
370 }
371
372 #[test]
376 fn absent_s3_optionals_omitted_from_json() {
377 let code = sample_s3_code("lib-omit");
378 let encoded = encode(&code);
379 let payload = encoded.strip_prefix(code_envelope::PREFIX).unwrap();
380 let bytes = URL_SAFE_NO_PAD.decode(payload).unwrap();
381 let json = String::from_utf8(bytes).unwrap();
382 assert!(!json.contains("endpoint"), "{json}");
383 assert!(!json.contains("key_prefix"), "{json}");
384 }
385
386 #[test]
387 fn round_trip_google_drive() {
388 let mut code = sample_s3_code("lib-789");
389 code.store_name = "Cloud Shared".into();
390 code.join_info = CloudHomeJoinInfo::GoogleDrive {
391 folder_id: "abc123".into(),
392 };
393 let encoded = encode(&code);
394 let decoded = decode(&encoded).unwrap();
395 assert_eq!(decoded.store_id, "lib-789");
396 match decoded.join_info {
397 CloudHomeJoinInfo::GoogleDrive { folder_id } => assert_eq!(folder_id, "abc123"),
398 _ => panic!("expected GoogleDrive variant"),
399 }
400 }
401
402 #[test]
403 fn decode_missing_prefix() {
404 let code = sample_s3_code("lib-noprefix");
405 let encoded = encode(&code);
406 let without_prefix = &encoded[code_envelope::PREFIX.len()..];
407 assert!(matches!(
408 decode(without_prefix),
409 Err(JoinCodeError::MissingPrefix)
410 ));
411 }
412
413 #[test]
414 fn decode_invalid_base64() {
415 assert!(matches!(
416 decode("coven:not-valid!!!"),
417 Err(JoinCodeError::InvalidBase64)
418 ));
419 }
420
421 #[test]
422 fn decode_invalid_json() {
423 let b64 = URL_SAFE_NO_PAD.encode(b"not json");
424 let encoded = format!("coven:{b64}");
425 assert!(matches!(
426 decode(&encoded),
427 Err(JoinCodeError::InvalidJson(_))
428 ));
429 }
430
431 #[test]
432 fn decode_obsolete_version() {
433 let mut code = sample_s3_code("lib-old");
434 code.v = 0;
435 let encoded = encode(&code);
436 assert!(matches!(
437 decode(&encoded),
438 Err(JoinCodeError::UnsupportedVersion(0))
439 ));
440 }
441
442 #[test]
443 fn decode_newer_version() {
444 let mut code = sample_s3_code("lib-new");
445 code.v = 99;
446 let encoded = encode(&code);
447 assert!(matches!(
448 decode(&encoded),
449 Err(JoinCodeError::UnsupportedVersion(99))
450 ));
451 }
452
453 #[test]
458 fn decode_missing_membership_floor_is_refused() {
459 let mut json = serde_json::to_value(sample_s3_code("lib-no-floor")).unwrap();
460 json.as_object_mut().unwrap().remove("membership_floor");
461 let bytes = serde_json::to_vec(&json).unwrap();
462 let encoded = format!("{}{}", code_envelope::PREFIX, URL_SAFE_NO_PAD.encode(bytes));
463 assert!(matches!(
464 decode(&encoded),
465 Err(JoinCodeError::InvalidJson(_))
466 ));
467 }
468
469 #[test]
470 fn decode_empty_membership_floor_is_refused() {
471 let mut code = sample_s3_code("lib-empty-floor");
472 code.membership_floor = MembershipFloor::MergeConcurrent(Vec::new());
473 assert!(matches!(
474 decode(&encode(&code)),
475 Err(JoinCodeError::EmptyMembershipFloor)
476 ));
477 }
478
479 #[test]
480 fn decode_invalid_owner_pubkey_wrong_length() {
481 let mut code = sample_s3_code("lib-short-key");
482 code.owner_pubkey = hex::encode([0xABu8; 16]);
483 let encoded = encode(&code);
484 assert!(matches!(
485 decode(&encoded),
486 Err(JoinCodeError::InvalidOwnerPubkey(_))
487 ));
488 }
489
490 #[test]
491 fn decode_invalid_owner_pubkey_non_hex() {
492 let mut code = sample_s3_code("lib-bad-hex-key");
493 code.owner_pubkey = "not hex".to_string();
494 let encoded = encode(&code);
495 assert!(matches!(
496 decode(&encoded),
497 Err(JoinCodeError::InvalidOwnerPubkey(_))
498 ));
499 }
500
501 #[test]
502 fn round_trip_cloudkit() {
503 let mut code = sample_s3_code("lib-ck");
504 code.store_name = "CloudKit Store".into();
505 code.join_info = CloudHomeJoinInfo::CloudKit;
506 let encoded = encode(&code);
507 let decoded = decode(&encoded).unwrap();
508 assert_eq!(decoded.store_id, "lib-ck");
509 assert!(matches!(decoded.join_info, CloudHomeJoinInfo::CloudKit));
510 }
511
512 #[test]
513 fn round_trip_cloudkit_share() {
514 let mut code = sample_s3_code("lib-ck-share");
515 code.store_name = "CloudKit Store".into();
516 code.join_info = CloudHomeJoinInfo::CloudKitShare {
517 share_url: "https://www.icloud.com/share/example".into(),
518 owner_name: "_owner".into(),
519 zone_name: "bae-store".into(),
520 };
521 let encoded = encode(&code);
522 let decoded = decode(&encoded).unwrap();
523 assert_eq!(decoded.store_id, "lib-ck-share");
524 assert!(matches!(
525 decoded.join_info,
526 CloudHomeJoinInfo::CloudKitShare {
527 share_url,
528 owner_name,
529 zone_name
530 } if share_url == "https://www.icloud.com/share/example"
531 && owner_name == "_owner"
532 && zone_name == "bae-store"
533 ));
534 }
535
536 #[test]
537 fn decode_trims_whitespace() {
538 let mut code = sample_s3_code("lib-ws");
539 code.store_name = "Trimmed".into();
540 code.join_info = CloudHomeJoinInfo::Dropbox {
541 folder_path: "/Apps/your-app/sf1".into(),
542 };
543 let encoded = format!(" {} \n", encode(&code));
544 let decoded = decode(&encoded).unwrap();
545 assert_eq!(decoded.store_id, "lib-ws");
546 }
547
548 #[test]
549 fn join_request_round_trip_with_email() {
550 let code = JoinRequestCode {
551 public_key: "abcdef1234567890".into(),
552 email: Some("[email protected]".into()),
553 };
554 let encoded = encode_join_request(&code);
555 let decoded = decode_join_request(&encoded).unwrap();
556 assert_eq!(decoded.public_key, "abcdef1234567890");
557 assert_eq!(decoded.email, Some("[email protected]".to_string()));
558 }
559
560 #[test]
561 fn join_request_round_trip_without_email() {
562 let code = JoinRequestCode {
563 public_key: "deadbeef".into(),
564 email: None,
565 };
566 let encoded = encode_join_request(&code);
567 let decoded = decode_join_request(&encoded).unwrap();
568 assert_eq!(decoded.public_key, "deadbeef");
569 assert_eq!(decoded.email, None);
570 }
571
572 #[test]
573 fn join_request_trims_whitespace() {
574 let code = JoinRequestCode {
575 public_key: "aabbccdd".into(),
576 email: None,
577 };
578 let encoded = format!(" {} \n", encode_join_request(&code));
579 let decoded = decode_join_request(&encoded).unwrap();
580 assert_eq!(decoded.public_key, "aabbccdd");
581 }
582}