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