Skip to main content

coven_core/
join_code.rs

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/// An invite is always for a private home: sharing wraps and rotates the store
24/// key, which a public (plaintext) home has none of, so the joiner always builds
25/// an encrypted, obfuscated home. The invite therefore carries no visibility
26/// flag.
27///
28/// `Debug` is derived: `join_info` (`CloudHomeJoinInfo`) already hand-writes
29/// its own redacting `Debug`, and no other field here carries a secret.
30#[derive(Serialize, Deserialize, Debug)]
31pub struct InviteCode {
32    /// Wire-format version.
33    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    /// The exact causal membership heads the joiner must observe.
41    pub membership_floor: MembershipFloor,
42}
43
44/// Encode an `InviteCode` into a prefixed base64url string.
45pub fn encode(code: &InviteCode) -> String {
46    code_envelope::encode_code(code_envelope::PREFIX, code)
47}
48
49/// Decode an invite code string back into an `InviteCode`.
50pub 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    // An invite is unsigned, so `store_id` is attacker-controlled. It becomes the
56    // name of a directory the joiner creates under `stores/` and recursively
57    // deletes on a bootstrap failure, so a value carrying `..`, a separator, or an
58    // absolute path would put that create/delete outside the stores root. Reject
59    // it the moment the code is parsed: a decoded `InviteCode` always carries a
60    // `store_id` that is a single safe path component.
61    crate::store_dir::validate_path_token(&code.store_id).map_err(JoinCodeError::InvalidStoreId)?;
62    // `owner_pubkey` pins the membership-chain founder the joiner authenticates
63    // the invite against (`join.rs`): a malformed value can't name a real chain,
64    // so it is refused here rather than surfacing as an opaque chain-lookup
65    // failure deep in the join.
66    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/// `Debug` is derived: neither field carries a secret.
93#[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
100/// Generate a join request code containing this device's Ed25519 public key and
101/// optionally a contact email the inviter can use to recognize the device.
102pub 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
114/// A join request carries no prefix or version: it is a short-lived exchange
115/// between the joiner and inviter, not a durable code a user stores and pastes
116/// back in later, so it reuses the envelope with an empty prefix.
117pub 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
125/// UI-ready info from a decoded invite code.
126pub 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    /// Whether the joining device must run an OAuth flow before joining, so the
133    /// host fetches the token first — mirrors `RestoreCodeInfo::needs_oauth`.
134    pub needs_oauth: bool,
135}
136
137/// Decode an invite code and return UI-ready info.
138pub 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    /// The invite's `store_id` is not a safe path component, so it cannot name a
162    /// store directory under `stores/`. The invite is unsigned and anyone can
163    /// craft one, so the id is refused here at decode rather than reaching a path
164    /// operation.
165    #[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    /// `owner_pubkey` pins the membership-chain founder (`join.rs`); a value
170    /// that isn't 32 bytes of hex can't name one, so it is refused at decode.
171    #[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    /// Absent optional S3 fields (`endpoint`, `key_prefix`) must not appear in
342    /// the encoded JSON at all — a smaller invite code, and a `None` that isn't
343    /// confusable with an explicit `null`.
344    #[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    /// `membership_floor` is required, not merely present-when-known: a code
423    /// serialized without it by a hand-crafted attack code
424    /// must be refused at decode rather than silently read as "no floor" — the
425    /// exact masking this field exists to remove.
426    #[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}