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 = 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/// An invite is always for a private home: sharing wraps and rotates the store
30/// key, which a public (plaintext) home has none of, so the joiner always builds
31/// an encrypted, obfuscated home. The invite therefore carries no visibility
32/// flag.
33///
34/// `Debug` is derived: `join_info` (`CloudHomeJoinInfo`) already hand-writes
35/// its own redacting `Debug`, and no other field here carries a secret.
36#[derive(Serialize, Deserialize, Debug)]
37pub struct InviteCode {
38    /// Wire-format version.
39    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    /// The exact membership state the joiner must observe: causal author
47    /// heads for MergeConcurrent stores, or the exact global commit for Serial
48    /// stores.
49    pub membership_floor: MembershipFloor,
50}
51
52/// Encode an `InviteCode` into a prefixed base64url string.
53pub fn encode(code: &InviteCode) -> String {
54    code_envelope::encode_code(code_envelope::PREFIX, code)
55}
56
57/// Decode an invite code string back into an `InviteCode`.
58pub 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    // An invite is unsigned, so `store_id` is attacker-controlled. It becomes the
64    // name of a directory the joiner creates under `stores/` and recursively
65    // deletes on a bootstrap failure, so a value carrying `..`, a separator, or an
66    // absolute path would put that create/delete outside the stores root. Reject
67    // it the moment the code is parsed: a decoded `InviteCode` always carries a
68    // `store_id` that is a single safe path component.
69    crate::store_dir::validate_path_token(&code.store_id).map_err(JoinCodeError::InvalidStoreId)?;
70    // `owner_pubkey` pins the membership-chain founder the joiner authenticates
71    // the invite against (`join.rs`): a malformed value can't name a real chain,
72    // so it is refused here rather than surfacing as an opaque chain-lookup
73    // failure deep in the join.
74    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/// `Debug` is derived: neither field carries a secret.
108#[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
115/// Generate a join request code containing this device's Ed25519 public key and
116/// optionally a contact email the inviter can use to recognize the device.
117pub 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
129/// A join request carries no prefix or version: it is a short-lived exchange
130/// between the joiner and inviter, not a durable code a user stores and pastes
131/// back in later, so it reuses the envelope with an empty prefix.
132pub 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
140/// UI-ready info from a decoded invite code.
141pub 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    /// Whether the joining device must run an OAuth flow before joining, so the
148    /// host fetches the token first — mirrors `RestoreCodeInfo::needs_oauth`.
149    pub needs_oauth: bool,
150}
151
152/// Decode an invite code and return UI-ready info.
153pub 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    /// The invite's `store_id` is not a safe path component, so it cannot name a
177    /// store directory under `stores/`. The invite is unsigned and anyone can
178    /// craft one, so the id is refused here at decode rather than reaching a path
179    /// operation.
180    #[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    /// `owner_pubkey` pins the membership-chain founder (`join.rs`); a value
185    /// that isn't 32 bytes of hex can't name one, so it is refused at decode.
186    #[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    /// Absent optional S3 fields (`endpoint`, `key_prefix`) must not appear in
373    /// the encoded JSON at all — a smaller invite code, and a `None` that isn't
374    /// confusable with an explicit `null`.
375    #[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    /// `membership_floor` is required, not merely present-when-known: a code
454    /// serialized without it by a hand-crafted attack code
455    /// must be refused at decode rather than silently read as "no floor" — the
456    /// exact masking this field exists to remove.
457    #[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}