Skip to main content

coven_core/sync/
circle.rs

1//! Circle identities, audience routing, and control coordinates.
2
3use std::fmt;
4use std::str::FromStr;
5
6use hkdf::Hkdf;
7use hmac::{Hmac, Mac};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use sha2::Sha256;
10
11use super::membership::MembershipGrantId;
12use super::store_commit::ObjectHash;
13use crate::encryption::EncryptionService;
14
15pub use super::circle_control::*;
16pub use super::circle_roster::*;
17
18const CIRCLE_ID_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
19const CIRCLE_ID_LENGTH: usize = 26;
20const ROW_ROUTING_KEY_DOMAIN: &[u8] = b"coven.row-routing.v1";
21const ROW_ROUTING_ID_DOMAIN: &[u8] = b"coven.row-routing-id.v1\0";
22const CIRCLE_ID_FOUNDER_DOMAIN: &str = "coven.circle-id-founder.v1";
23const CIRCLE_EPOCH_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-epoch-id-generation.v1\0";
24const ACCESS_LEAF_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-access-leaf-id-generation.v1\0";
25
26pub const CIRCLE_CONTROL_PREFIX: &str = "circle-control/";
27pub const CIRCLE_ROSTER_PREFIX: &str = "circles/";
28pub const CIRCLE_METADATA_PREFIX: &str = "circles/";
29pub const CIRCLE_ACCESS_LEAF_PREFIX: &str = "circles/";
30pub const CIRCLE_ACCESS_ENVELOPE_PREFIX: &str = "circles/";
31
32/// A self-certifying 128-bit circle identity encoded as canonical lowercase base32.
33#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct CircleId([u8; 16]);
35
36impl CircleId {
37    pub(crate) fn founder(
38        store_root_hash: ObjectHash,
39        author_pubkey: &str,
40        owner_grant: &MembershipGrantId,
41    ) -> Self {
42        #[derive(Serialize)]
43        struct Founder<'a> {
44            domain: &'static str,
45            store_root_hash: ObjectHash,
46            author_pubkey: &'a str,
47            owner_grant: &'a MembershipGrantId,
48        }
49        let digest = ObjectHash::digest(
50            &serde_json::to_vec(&Founder {
51                domain: CIRCLE_ID_FOUNDER_DOMAIN,
52                store_root_hash,
53                author_pubkey,
54                owner_grant,
55            })
56            .expect("Circle ID founder serialization cannot fail"),
57        );
58        let mut bytes = [0_u8; 16];
59        bytes.copy_from_slice(&digest.as_bytes()[..16]);
60        Self(bytes)
61    }
62
63    pub fn from_bytes(bytes: [u8; 16]) -> Self {
64        Self(bytes)
65    }
66
67    pub fn as_bytes(&self) -> &[u8; 16] {
68        &self.0
69    }
70}
71
72impl fmt::Debug for CircleId {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        fmt::Display::fmt(self, formatter)
75    }
76}
77
78impl fmt::Display for CircleId {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter.write_str(&encode_base32(&self.0))
81    }
82}
83
84impl FromStr for CircleId {
85    type Err = CircleIdError;
86
87    fn from_str(value: &str) -> Result<Self, Self::Err> {
88        let bytes = decode_base32(value)?;
89        let id = Self(bytes);
90        if id.to_string() != value || value == "local" {
91            return Err(CircleIdError(value.to_string()));
92        }
93        Ok(id)
94    }
95}
96
97impl Serialize for CircleId {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: Serializer,
101    {
102        serializer.serialize_str(&self.to_string())
103    }
104}
105
106impl<'de> Deserialize<'de> for CircleId {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        String::deserialize(deserializer)?
112            .parse()
113            .map_err(serde::de::Error::custom)
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
118#[error("circle id must be canonical 128-bit lowercase base32: {0:?}")]
119pub struct CircleIdError(String);
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum CircleRole {
124    Owner,
125    Member,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct CircleInfo {
130    pub id: CircleId,
131    pub name: String,
132    pub role: CircleRole,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case", deny_unknown_fields)]
137pub enum CircleOperationState {
138    Pending,
139    Blocked { reason: String },
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct CircleOperationInfo {
144    pub circle_id: CircleId,
145    pub name: String,
146    pub state: CircleOperationState,
147}
148
149/// The one audience a synced row belongs to.
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub enum Audience {
152    Store,
153    Circle(CircleId),
154    Local,
155}
156
157impl Audience {
158    pub fn from_column(value: Option<&str>) -> Result<Self, CircleIdError> {
159        match value {
160            None => Ok(Self::Store),
161            Some("local") => Ok(Self::Local),
162            Some(circle) => circle.parse().map(Self::Circle),
163        }
164    }
165
166    pub fn column_value(&self) -> Option<String> {
167        match self {
168            Self::Store => None,
169            Self::Circle(circle) => Some(circle.to_string()),
170            Self::Local => Some("local".to_string()),
171        }
172    }
173}
174
175macro_rules! generated_hex_id {
176    ($name:ident, $domain:ident) => {
177        #[derive(
178            Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
179        )]
180        #[serde(transparent)]
181        pub struct $name([u8; 16]);
182
183        impl $name {
184            pub(crate) fn generate(ids: &dyn crate::id_provider::IdProvider) -> Self {
185                Self(generated_id_bytes(ids, $domain))
186            }
187        }
188
189        impl fmt::Display for $name {
190            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191                formatter.write_str(&hex::encode(self.0))
192            }
193        }
194    };
195}
196
197generated_hex_id!(CircleEpochId, CIRCLE_EPOCH_ID_GENERATION_DOMAIN);
198generated_hex_id!(AccessLeafId, ACCESS_LEAF_ID_GENERATION_DOMAIN);
199
200pub(crate) fn generated_id_digest(
201    ids: &dyn crate::id_provider::IdProvider,
202    domain: &[u8],
203) -> ObjectHash {
204    let id = ids.new_id();
205    let mut material = Vec::with_capacity(domain.len() + id.len());
206    material.extend_from_slice(domain);
207    material.extend_from_slice(id.as_bytes());
208    ObjectHash::digest(&material)
209}
210
211fn generated_id_bytes(ids: &dyn crate::id_provider::IdProvider, domain: &[u8]) -> [u8; 16] {
212    generated_id_digest(ids, domain).as_bytes()[..16]
213        .try_into()
214        .expect("SHA-256 digest prefix has fixed length")
215}
216
217/// HMAC identity of one scoped row. It is stable across audience moves and
218/// Store-key rotations because it derives from the unique generation-1 key.
219#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
220pub struct RowRoutingId([u8; 32]);
221
222impl fmt::Debug for RowRoutingId {
223    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224        fmt::Display::fmt(self, formatter)
225    }
226}
227
228impl fmt::Display for RowRoutingId {
229    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230        formatter.write_str(&hex::encode(self.0))
231    }
232}
233
234impl FromStr for RowRoutingId {
235    type Err = RowRoutingIdError;
236
237    fn from_str(value: &str) -> Result<Self, Self::Err> {
238        if value.len() != 64
239            || value
240                .bytes()
241                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
242        {
243            return Err(RowRoutingIdError(value.to_string()));
244        }
245        let bytes: [u8; 32] = hex::decode(value)
246            .map_err(|_| RowRoutingIdError(value.to_string()))?
247            .try_into()
248            .map_err(|_| RowRoutingIdError(value.to_string()))?;
249        Ok(Self(bytes))
250    }
251}
252
253impl Serialize for RowRoutingId {
254    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
255    where
256        S: Serializer,
257    {
258        serializer.serialize_str(&self.to_string())
259    }
260}
261
262impl<'de> Deserialize<'de> for RowRoutingId {
263    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
264    where
265        D: Deserializer<'de>,
266    {
267        String::deserialize(deserializer)?
268            .parse()
269            .map_err(serde::de::Error::custom)
270    }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
274#[error("row routing id must be exactly 64 lowercase hexadecimal characters: {0:?}")]
275pub struct RowRoutingIdError(String);
276
277#[derive(Clone)]
278pub(crate) struct RowRoutingKey([u8; 32]);
279
280#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
281pub enum RowRoutingKeyError {
282    #[error("Store keyring has no generation-1 key")]
283    MissingGenerationOne,
284    #[error("Store keyring has more than one generation-1 key")]
285    AmbiguousGenerationOne,
286}
287
288pub(crate) fn derive_row_routing_key(
289    encryption: &EncryptionService,
290    store_root_hash: ObjectHash,
291) -> Result<RowRoutingKey, RowRoutingKeyError> {
292    let mut generation_one = encryption
293        .keyring_entries()
294        .into_iter()
295        .filter_map(|(generation, key)| (generation == 1).then_some(key));
296    let key = generation_one
297        .next()
298        .ok_or(RowRoutingKeyError::MissingGenerationOne)?;
299    if generation_one.next().is_some() {
300        return Err(RowRoutingKeyError::AmbiguousGenerationOne);
301    }
302    let hkdf = Hkdf::<Sha256>::new(Some(ROW_ROUTING_KEY_DOMAIN), &key);
303    let mut derived = [0u8; 32];
304    hkdf.expand(store_root_hash.as_bytes(), &mut derived)
305        .expect("32 bytes is a valid HKDF output length");
306    Ok(RowRoutingKey(derived))
307}
308
309pub(crate) fn row_routing_id(key: &RowRoutingKey, table: &str, row_id: &str) -> RowRoutingId {
310    let mut mac = Hmac::<Sha256>::new_from_slice(&key.0).expect("HMAC accepts a 32-byte key");
311    mac.update(ROW_ROUTING_ID_DOMAIN);
312    mac.update(&(table.len() as u64).to_be_bytes());
313    mac.update(table.as_bytes());
314    mac.update(&(row_id.len() as u64).to_be_bytes());
315    mac.update(row_id.as_bytes());
316    RowRoutingId(mac.finalize().into_bytes().into())
317}
318
319fn encode_base32(bytes: &[u8; 16]) -> String {
320    let mut output = String::with_capacity(CIRCLE_ID_LENGTH);
321    let mut buffer = 0u32;
322    let mut bits = 0u8;
323    for byte in bytes {
324        buffer = (buffer << 8) | u32::from(*byte);
325        bits += 8;
326        while bits >= 5 {
327            bits -= 5;
328            output.push(CIRCLE_ID_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
329            buffer &= (1u32 << bits).wrapping_sub(1);
330        }
331    }
332    if bits != 0 {
333        output.push(CIRCLE_ID_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
334    }
335    output
336}
337
338fn decode_base32(value: &str) -> Result<[u8; 16], CircleIdError> {
339    if value.len() != CIRCLE_ID_LENGTH {
340        return Err(CircleIdError(value.to_string()));
341    }
342    let mut output = Vec::with_capacity(16);
343    let mut buffer = 0u32;
344    let mut bits = 0u8;
345    for byte in value.bytes() {
346        let digit = CIRCLE_ID_ALPHABET
347            .iter()
348            .position(|candidate| *candidate == byte)
349            .ok_or_else(|| CircleIdError(value.to_string()))? as u32;
350        buffer = (buffer << 5) | digit;
351        bits += 5;
352        while bits >= 8 {
353            bits -= 8;
354            output.push(((buffer >> bits) & 0xff) as u8);
355            buffer &= (1u32 << bits).wrapping_sub(1);
356        }
357    }
358    if output.len() != 16 || buffer != 0 {
359        return Err(CircleIdError(value.to_string()));
360    }
361    output
362        .try_into()
363        .map_err(|_| CircleIdError(value.to_string()))
364}
365
366#[cfg(test)]
367mod tests {
368    use std::collections::BTreeMap;
369
370    use super::super::circle_control::{merkle_root_and_proofs, verify_merkle_proof};
371    use super::*;
372    use crate::keys::{self, UserKeypair};
373
374    fn candidate_family(label: &str) -> super::super::store_commit::CandidateFamilyId {
375        super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
376            label.as_bytes(),
377        ))
378    }
379
380    fn exact_object(label: &str, bytes: &[u8]) -> super::super::storage::ExactObjectRef {
381        super::super::storage::ExactObjectRef::new(
382            crate::storage::cloud::ObjectSlot::logical(format!("store-v1/test/{label}.json"))
383                .unwrap(),
384            bytes.len() as u64,
385            ObjectHash::digest(bytes),
386        )
387    }
388
389    fn exact_logical_object(
390        logical_key: String,
391        bytes: &[u8],
392    ) -> super::super::storage::ExactObjectRef {
393        super::super::storage::ExactObjectRef::new(
394            crate::storage::cloud::ObjectSlot::logical(logical_key).unwrap(),
395            bytes.len() as u64,
396            ObjectHash::digest(bytes),
397        )
398    }
399
400    fn test_founder_entry(
401        label: &str,
402        owner: &UserKeypair,
403        membership: super::super::store_commit::GrantStreamAnchor,
404    ) -> super::super::membership::MembershipEntry {
405        super::super::membership::founder_entry(
406            label,
407            owner,
408            crate::sync::test_helpers::test_membership_grant_id(label),
409            "founder",
410            membership,
411            crate::sync::test_helpers::test_founder_provider_admin(label),
412        )
413    }
414
415    fn merge_membership_ref(
416        owner: &UserKeypair,
417        members: &[(String, super::super::membership::MemberRole)],
418        label: &str,
419    ) -> (
420        StoreMembershipStateRef,
421        super::super::membership::MembershipGrantCreationAuthority,
422    ) {
423        let founder = test_founder_entry(
424            label,
425            owner,
426            super::super::store_commit::GrantStreamAnchor::StoreMembership {
427                first_slot: crate::storage::cloud::ObjectSlot::logical(format!(
428                    "store-v1/test/{label}/membership/1.json"
429                ))
430                .unwrap(),
431            },
432        );
433        let founder_coord = founder.coord();
434        let mut chain = super::super::membership::MembershipChain::from_entries(vec![founder])
435            .expect("found merge-concurrent membership");
436        for (index, (pubkey, role)) in members.iter().enumerate() {
437            if pubkey == &keys::public_key_hex(owner) {
438                continue;
439            }
440            let entry = chain
441                .signed_set_member_in_stream(
442                    owner,
443                    founder_coord.stream_id,
444                    pubkey.clone(),
445                    None,
446                    role.clone(),
447                    format!("member-{index}"),
448                )
449                .expect("sign merge-concurrent member");
450            chain
451                .add_entry(entry)
452                .expect("apply merge-concurrent member");
453        }
454        let resolved = match chain.status() {
455            super::super::membership::MembershipStatus::Resolved(resolved) => resolved,
456            super::super::membership::MembershipStatus::Conflict(_) => {
457                panic!("membership fixture must resolve")
458            }
459        };
460        let tip = chain.entries().last().expect("membership tip").coord();
461        let head = super::super::membership::MembershipHeadRef {
462            coord: tip,
463            head_hash: ObjectHash::digest(format!("{label} head").as_bytes()),
464            object: exact_object(&format!("{label}/membership-head"), b"membership head"),
465        };
466        (
467            StoreMembershipStateRef::merge_concurrent(
468                vec![head],
469                Vec::new(),
470                Vec::new(),
471                resolved.state_hash,
472            )
473            .expect("valid merge-concurrent membership reference"),
474            super::super::membership::MembershipGrantCreationAuthority::Entry(founder_coord),
475        )
476    }
477
478    fn serial_membership_ref(
479        owner: &UserKeypair,
480        members: &[(String, super::super::membership::MemberRole)],
481        label: &str,
482    ) -> StoreMembershipStateRef {
483        let root_bytes = format!("{label} root").into_bytes();
484        let root = super::super::store_commit::StoreRootRef {
485            store_root_id: ObjectHash::digest(format!("{label} identity").as_bytes()),
486            store_root_hash: ObjectHash::digest(&root_bytes),
487            object: exact_object(&format!("{label}/root"), &root_bytes),
488        };
489        let origin = super::super::store_commit::StoreDeviceRegistrationOrigin::Founder {
490            creation_id: super::super::store_commit::StoreCreationId::from_nonce(label),
491        };
492        let founder_registration = super::super::store_commit::StoreDeviceRegistrationRef {
493            device_id: super::super::store_commit::StoreDeviceId::derive(&root, &origin),
494            registration_hash: ObjectHash::digest(format!("{label} registration").as_bytes()),
495            object: exact_object(
496                &format!("{label}/registration"),
497                format!("{label} registration").as_bytes(),
498            ),
499        };
500        let founder = test_founder_entry(
501            label,
502            owner,
503            super::super::store_commit::GrantStreamAnchor::StoreMembership {
504                first_slot: crate::storage::cloud::ObjectSlot::logical(format!(
505                    "store-v1/test/{label}/membership/1.json"
506                ))
507                .unwrap(),
508            },
509        );
510        let mut membership = super::super::membership::SerialMembershipState::from_founder(
511            root.store_root_id,
512            &founder,
513        )
514        .expect("found Serial membership");
515        for (index, (pubkey, role)) in members.iter().enumerate() {
516            if pubkey == &keys::public_key_hex(owner) {
517                continue;
518            }
519            let entry = membership
520                .signed_set_member(
521                    owner,
522                    pubkey.clone(),
523                    None,
524                    role.clone(),
525                    format!("member-{index}"),
526                )
527                .expect("sign Serial member");
528            membership = membership.apply(&entry).expect("apply Serial member");
529        }
530        let authorization =
531            super::super::membership::SerialAuthorizationState::from_test_membership(
532                &founder, membership,
533            )
534            .expect("test Serial authorization");
535        StoreMembershipStateRef::serial(
536            super::super::store_commit::SerialStorePosition::Genesis {
537                root,
538                founder_registration,
539            },
540            Vec::new(),
541            &authorization,
542        )
543        .expect("valid Serial membership reference")
544    }
545
546    struct MergeDeviceAuthority {
547        registration: super::super::store_commit::StoreDeviceRegistration,
548        reference: super::super::store_commit::StoreDeviceRegistrationRef,
549        device_signer: UserKeypair,
550        stream_id: super::super::membership::AuthorStreamId,
551    }
552
553    fn merge_device_authority(
554        identity: &UserKeypair,
555        store_root_hash: ObjectHash,
556        label: &str,
557    ) -> MergeDeviceAuthority {
558        let root = super::super::store_commit::StoreRootRef {
559            store_root_id: ObjectHash::digest(format!("{label} identity").as_bytes()),
560            store_root_hash,
561            object: exact_object(&format!("{label}/root"), label.as_bytes()),
562        };
563        let slot = |stream: &str| {
564            crate::storage::cloud::ObjectSlot::logical(format!(
565                "store-v1/test/{label}/{stream}/1.json"
566            ))
567            .unwrap()
568        };
569        let registration = super::super::store_commit::StoreDeviceRegistration::signed(
570            root.clone(),
571            super::super::store_commit::StoreDeviceRegistrationOrigin::Founder {
572                creation_id: super::super::store_commit::StoreCreationId::from_nonce(label),
573            },
574            super::super::storage::ProviderDeviceBinding {
575                principal: super::super::storage::ProviderPrincipalId::CustomS3Credential {
576                    access_key_id_hash: ObjectHash::digest(label.as_bytes()),
577                },
578            },
579            super::super::store_commit::StoreCommitAnchor::MergeConcurrent {
580                announcements: super::super::store_commit::DeviceStreamAnchor::StoreAnnouncements {
581                    first_slot: slot("announcements"),
582                },
583            },
584            super::super::store_commit::DeviceStreamAnchor::StoreAcknowledgements {
585                first_slot: slot("acknowledgements"),
586            },
587            super::super::store_commit::DeviceStreamAnchor::StoreSnapshots {
588                first_slot: slot("snapshots"),
589            },
590            identity,
591        )
592        .expect("sign test device registration");
593        let bytes = registration.to_bytes();
594        let reference = super::super::store_commit::StoreDeviceRegistrationRef::from_registration(
595            &registration,
596            exact_object(&format!("{label}/registration"), &bytes),
597        );
598        let device_signer = registration
599            .device_signer(identity)
600            .expect("derive registered device signer");
601        let stream_id =
602            super::super::membership::AuthorStreamId::store_announcements(&root, &reference);
603        MergeDeviceAuthority {
604            registration,
605            reference,
606            device_signer,
607            stream_id,
608        }
609    }
610
611    fn test_activation_objects(label: &str) -> super::super::store_commit::CircleActivationObjects {
612        let bytes = label.as_bytes();
613        super::super::store_commit::CircleActivationObjects {
614            control: super::super::storage::ExactObjectRef::new(
615                crate::storage::cloud::ObjectSlot::logical(format!(
616                    "store-v1/test-circle-controls/{label}.json"
617                ))
618                .expect("test control slot is valid"),
619                bytes.len() as u64,
620                ObjectHash::digest(bytes),
621            ),
622            control_head: None,
623            roster_entries: BTreeMap::new(),
624            roster_heads: BTreeMap::new(),
625            roster_resolutions: BTreeMap::new(),
626            metadata_entries: BTreeMap::new(),
627            metadata_heads: BTreeMap::new(),
628            access: Vec::new(),
629        }
630    }
631
632    #[test]
633    fn merkle_proofs_verify_for_every_leaf_in_even_and_odd_layers() {
634        for leaf_count in 1..=9 {
635            let leaves = (0..leaf_count)
636                .map(|index| ObjectHash::digest(format!("leaf-{index}").as_bytes()))
637                .collect::<Vec<_>>();
638            let (root, proofs) = merkle_root_and_proofs(&leaves);
639            assert_eq!(proofs.len(), leaves.len());
640            for (index, (leaf, proof)) in leaves.iter().zip(&proofs).enumerate() {
641                assert!(
642                    verify_merkle_proof(*leaf, proof, root),
643                    "leaf {index} of {leaf_count} failed its canonical proof"
644                );
645            }
646        }
647    }
648
649    #[test]
650    fn founder_payload_is_complete_and_acyclic_for_both_store_policies() {
651        let owner = crate::keys::UserKeypair::generate();
652        let peer = crate::keys::UserKeypair::generate();
653        let owner_pubkey = crate::keys::public_key_hex(&owner);
654        let peer_pubkey = crate::keys::public_key_hex(&peer);
655        let members = vec![
656            (
657                owner_pubkey.clone(),
658                super::super::membership::MemberRole::Owner,
659            ),
660            (
661                peer_pubkey.clone(),
662                super::super::membership::MemberRole::Member,
663            ),
664        ];
665
666        let (merge_membership, merge_authority) =
667            merge_membership_ref(&owner, &members, "founder-circle-merge");
668        for (membership, membership_authority) in [
669            (merge_membership, Some(merge_authority)),
670            (
671                serial_membership_ref(&owner, &members, "founder-circle-serial"),
672                None,
673            ),
674        ] {
675            let ids = crate::id_provider::SequentialIdProvider::new("founder-circle");
676            let candidate_family = candidate_family("founder-circle");
677            let creation = CircleCreation::founder(
678                ObjectHash::digest(b"store-root"),
679                candidate_family,
680                "device-a",
681                "Household",
682                "0000000001000-0000-device-a",
683                membership,
684                membership_authority,
685                members.clone(),
686                &ids,
687                &owner,
688            )
689            .expect("construct founder circle");
690
691            assert!(creation.control.verify());
692            assert!(creation.metadata.verify());
693            assert!(creation.resolved_roster().verify());
694            assert_eq!(creation.access.len(), 2);
695            for access in &creation.access {
696                assert!(access.leaf.verify(&creation.control, candidate_family));
697                assert!(access.envelope.verify(&creation.control, candidate_family));
698                assert!(access.leaf.verify_envelope(
699                    &creation.control,
700                    &access.envelope,
701                    candidate_family
702                ));
703                assert!(!access.leaf.bytes.windows(64).any(|window| {
704                    window == creation.control.coord.control_hash().to_string().as_bytes()
705                }));
706            }
707            assert!(matches!(
708                creation
709                    .access
710                    .iter()
711                    .find(|access| access.leaf.value.recipient_pubkey == owner_pubkey)
712                    .unwrap()
713                    .leaf
714                    .value
715                    .disposition,
716                CircleAccessDisposition::Active { .. }
717            ));
718            assert!(matches!(
719                creation
720                    .access
721                    .iter()
722                    .find(|access| access.leaf.value.recipient_pubkey == peer_pubkey)
723                    .unwrap()
724                    .leaf
725                    .value
726                    .disposition,
727                CircleAccessDisposition::Inactive
728            ));
729
730            if matches!(
731                creation.control.value.order,
732                CircleControlOrder::MergeConcurrent { .. }
733            ) {
734                let mut seized = creation.control.value.clone();
735                seized.circle_id = CircleId::from_bytes([0x5a; 16]);
736                seized.signature = keys::sign_hex(&owner, &seized.canonical_bytes()).1;
737                assert!(
738                    !seized.verify(),
739                    "a founder control must not choose an arbitrary Circle ID"
740                );
741
742                let mut discontinuous = creation.control.value.clone();
743                let CircleControlOrder::MergeConcurrent { seq, .. } = &mut discontinuous.order
744                else {
745                    unreachable!()
746                };
747                *seq = 2;
748                discontinuous.signature =
749                    keys::sign_hex(&owner, &discontinuous.canonical_bytes()).1;
750                assert!(
751                    !discontinuous.verify(),
752                    "a control without a predecessor must be genesis"
753                );
754            }
755        }
756    }
757
758    #[test]
759    fn access_verification_rejects_signed_context_and_proof_substitution() {
760        let owner = crate::keys::UserKeypair::generate();
761        let peer = crate::keys::UserKeypair::generate();
762        let owner_pubkey = crate::keys::public_key_hex(&owner);
763        let peer_pubkey = crate::keys::public_key_hex(&peer);
764        let members = vec![
765            (
766                owner_pubkey.clone(),
767                super::super::membership::MemberRole::Owner,
768            ),
769            (
770                peer_pubkey.clone(),
771                super::super::membership::MemberRole::Member,
772            ),
773        ];
774        let (membership, authority) = merge_membership_ref(&owner, &members, "access-verification");
775        let ids = crate::id_provider::SequentialIdProvider::new("access-verification");
776        let candidate_family = candidate_family("access-verification");
777        let creation = CircleCreation::founder(
778            ObjectHash::digest(b"store-root"),
779            candidate_family,
780            "device-a",
781            "Household",
782            "0000000001000-0000-device-a",
783            membership,
784            Some(authority),
785            members.clone(),
786            &ids,
787            &owner,
788        )
789        .expect("construct founder circle");
790
791        let mut wrong_store = creation.access[0].envelope.clone();
792        wrong_store.store_root_hash = ObjectHash::digest(b"other-store");
793
794        wrong_store.signature = keys::sign_hex(&owner, &wrong_store.canonical_bytes()).1;
795        assert!(!wrong_store.verify(&creation.control, candidate_family));
796
797        let mut wrong_family_envelope = creation.access[0].envelope.clone();
798        wrong_family_envelope.candidate_family =
799            super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
800                b"other access family",
801            ));
802        wrong_family_envelope.signature =
803            keys::sign_hex(&owner, &wrong_family_envelope.canonical_bytes()).1;
804        assert!(!wrong_family_envelope.verify(&creation.control, candidate_family));
805
806        let mut wrong_family_leaf = creation.access[0].leaf.clone();
807        wrong_family_leaf.value.candidate_family =
808            super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
809                b"other leaf family",
810            ));
811        wrong_family_leaf.value.signature =
812            keys::sign_hex(&owner, &wrong_family_leaf.value.canonical_bytes()).1;
813        assert!(!wrong_family_leaf.verify(&creation.control, candidate_family));
814
815        let mut non_owner = creation.access[0].envelope.clone();
816        non_owner.owner_pubkey = peer_pubkey;
817        non_owner.signature = keys::sign_hex(&peer, &non_owner.canonical_bytes()).1;
818        assert!(!non_owner.verify(&creation.control, candidate_family));
819
820        let mut substituted_proof = creation.access[0].envelope.clone();
821        substituted_proof.proof = creation.access[1].envelope.proof.clone();
822        substituted_proof.signature =
823            keys::sign_hex(&owner, &substituted_proof.canonical_bytes()).1;
824        assert!(!substituted_proof.verify(&creation.control, candidate_family));
825
826        let mut substituted_leaf_id = creation.access[0].envelope.clone();
827        substituted_leaf_id.leaf_id = creation.access[1].leaf.value.leaf_id;
828        substituted_leaf_id.signature =
829            keys::sign_hex(&owner, &substituted_leaf_id.canonical_bytes()).1;
830        assert!(substituted_leaf_id.verify(&creation.control, candidate_family));
831        assert!(!creation.access[0].leaf.verify_envelope(
832            &creation.control,
833            &substituted_leaf_id,
834            candidate_family,
835        ));
836
837        let mut wrong_membership_leaf = creation.access[0].leaf.value.clone();
838        wrong_membership_leaf.store_membership =
839            serial_membership_ref(&owner, &members, "wrong-membership-leaf");
840        wrong_membership_leaf.signature =
841            keys::sign_hex(&owner, &wrong_membership_leaf.canonical_bytes()).1;
842        let recipient_key =
843            keys::ed25519_to_x25519_public_key(&owner.public_key()).expect("convert recipient key");
844        let bytes = keys::seal_box_encrypt(
845            &serde_json::to_vec(&wrong_membership_leaf).expect("serialize forged leaf"),
846            &recipient_key,
847        );
848        let wrong_membership_leaf = PreparedAccessLeaf {
849            leaf_hash: ObjectHash::digest(&bytes),
850            bytes,
851            value: wrong_membership_leaf,
852        };
853        assert!(!wrong_membership_leaf.verify(&creation.control, candidate_family));
854
855        let mut wrong_keyring_leaf = creation
856            .access
857            .iter()
858            .find(|access| {
859                matches!(
860                    &access.leaf.value.disposition,
861                    CircleAccessDisposition::Active { .. }
862                )
863            })
864            .expect("founder access")
865            .leaf
866            .value
867            .clone();
868        let CircleAccessDisposition::Active { keyring, .. } = &mut wrong_keyring_leaf.disposition
869        else {
870            panic!("founder access must be active")
871        };
872        *keyring = crate::encryption::MasterKeyring::generate().to_serialized();
873        wrong_keyring_leaf.signature =
874            keys::sign_hex(&owner, &wrong_keyring_leaf.canonical_bytes()).1;
875        let bytes = keys::seal_box_encrypt(
876            &serde_json::to_vec(&wrong_keyring_leaf).expect("serialize wrong-keyring leaf"),
877            &recipient_key,
878        );
879        let wrong_keyring_leaf = PreparedAccessLeaf {
880            leaf_hash: ObjectHash::digest(&bytes),
881            bytes,
882            value: wrong_keyring_leaf,
883        };
884        assert!(!wrong_keyring_leaf.verify(&creation.control, candidate_family));
885
886        let mut wrong_policy_control = creation.control.value.clone();
887        wrong_policy_control.store_membership =
888            serial_membership_ref(&owner, &members, "wrong-policy-control");
889        wrong_policy_control.membership_authority = None;
890        wrong_policy_control.signature =
891            keys::sign_hex(&owner, &wrong_policy_control.canonical_bytes()).1;
892        assert!(!wrong_policy_control.verify());
893    }
894
895    #[test]
896    fn circle_id_round_trips_only_its_canonical_lowercase_base32() {
897        let id = CircleId::from_bytes([0; 16]);
898        let encoded = id.to_string();
899        assert_eq!(encoded.len(), CIRCLE_ID_LENGTH);
900        assert_eq!(encoded.parse::<CircleId>().unwrap(), id);
901        assert!(encoded.to_uppercase().parse::<CircleId>().is_err());
902        assert!("local".parse::<CircleId>().is_err());
903        assert!(format!("{}b", &encoded[..25]).parse::<CircleId>().is_err());
904    }
905
906    #[test]
907    fn recipient_slot_rejects_the_ed25519_identity_point() {
908        let local = UserKeypair::generate();
909        let mut identity = [0; keys::SIGN_PUBLICKEYBYTES];
910        identity[0] = 1;
911        let recipient = hex::encode(identity);
912
913        assert_eq!(
914            recipient_slot_with_peer(&local, &recipient, CircleId::from_bytes([9; 16])),
915            Err(CircleCreateError::InvalidRecipient(recipient))
916        );
917    }
918
919    #[test]
920    fn row_routing_id_is_stable_across_store_key_rotation() {
921        let root = ObjectHash::digest(b"store-root");
922        let before = EncryptionService::from_key([1u8; 32]);
923        let after = before
924            .with_appended_generation(2, [2u8; 32])
925            .expect("append generation");
926        let before_id = row_routing_id(
927            &derive_row_routing_key(&before, root).unwrap(),
928            "accounts",
929            "row-1",
930        );
931        let after_id = row_routing_id(
932            &derive_row_routing_key(&after, root).unwrap(),
933            "accounts",
934            "row-1",
935        );
936        assert_eq!(before_id, after_id);
937        assert_ne!(
938            before_id,
939            row_routing_id(
940                &derive_row_routing_key(&after, root).unwrap(),
941                "accounts",
942                "row-2",
943            )
944        );
945    }
946
947    #[test]
948    fn row_routing_key_requires_exactly_one_generation_one_key() {
949        let root = ObjectHash::digest(b"store-root");
950        let missing = EncryptionService::from_key_at_generation(2, [2u8; 32]);
951        assert!(matches!(
952            derive_row_routing_key(&missing, root),
953            Err(RowRoutingKeyError::MissingGenerationOne)
954        ));
955
956        let ambiguous = EncryptionService::from_keyring([(1, [1u8; 32]), (1, [2u8; 32])])
957            .expect("build forked generation one");
958        assert!(matches!(
959            derive_row_routing_key(&ambiguous, root),
960            Err(RowRoutingKeyError::AmbiguousGenerationOne)
961        ));
962    }
963
964    #[tokio::test]
965    async fn control_history_caches_the_verified_access_owner_and_rejects_second_genesis() {
966        let author = crate::keys::UserKeypair::generate();
967        let author_pubkey = crate::keys::public_key_hex(&author);
968        let earlier_owner = loop {
969            let candidate = crate::keys::UserKeypair::generate();
970            if crate::keys::public_key_hex(&candidate) < author_pubkey {
971                break candidate;
972            }
973        };
974        let earlier_owner_pubkey = crate::keys::public_key_hex(&earlier_owner);
975        let members = vec![
976            (
977                author_pubkey.clone(),
978                super::super::membership::MemberRole::Owner,
979            ),
980            (
981                earlier_owner_pubkey.clone(),
982                super::super::membership::MemberRole::Owner,
983            ),
984        ];
985        let store_root_hash = ObjectHash::digest(b"multi-owner-store-root");
986        let (membership, membership_authority) =
987            merge_membership_ref(&author, &members, "multi-owner-control");
988        let device = merge_device_authority(&author, store_root_hash, "multi-owner-device");
989        let ids = crate::id_provider::SequentialIdProvider::new("multi-owner-control");
990        let operation_id = crate::WriteId::from_generated("multi-owner-control-commit".to_string());
991        let order = super::super::store_commit::StoreCommitOrder::MergeConcurrent {
992            seq: 1,
993            predecessor: None,
994            dependencies: BTreeMap::new(),
995        };
996        let candidate_family = super::super::store_commit::CandidateFamilyId::derive(
997            store_root_hash,
998            &device.reference,
999            &operation_id,
1000            &order,
1001        );
1002        let creation = CircleCreation::founder(
1003            store_root_hash,
1004            candidate_family,
1005            &device.reference.device_id.to_string(),
1006            "Household",
1007            "0000000001000-0000-device-a",
1008            membership.clone(),
1009            Some(membership_authority.clone()),
1010            members,
1011            &ids,
1012            &author,
1013        )
1014        .expect("construct founder circle");
1015        let mut control = creation.control.value.clone();
1016        control.owners = vec![earlier_owner_pubkey, author_pubkey.clone()];
1017        control.owners.sort();
1018        assert_ne!(control.owners[0], control.author_pubkey);
1019        control.signature = keys::sign_hex(&author, &control.canonical_bytes()).1;
1020        let control = PreparedCircleControl {
1021            coord: control.coord(),
1022            bytes: serde_json::to_vec(&control).expect("serialize control"),
1023            value: control,
1024        };
1025        let reference = super::super::store_commit::CircleControlRef::MergeConcurrent {
1026            circle_id: creation.circle_id,
1027            control: control.coord.clone(),
1028            head_hash: ObjectHash::digest(b"multi-owner-control-head"),
1029            objects: test_activation_objects("multi-owner"),
1030        };
1031        let first_coord = super::super::store_commit::StoreCommitCoord::MergeConcurrent {
1032            stream_id: device.stream_id,
1033            sequence: 1,
1034        };
1035        let commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1036            store_root_hash,
1037            operation_id,
1038            first_coord.clone(),
1039            device.reference.clone(),
1040            &device.registration,
1041            order,
1042            membership.clone(),
1043            super::super::store_commit::StoreDeviceStateRef::MergeConcurrent {
1044                frontier: super::super::store_commit::CommitFrontier::MergeConcurrent(
1045                    BTreeMap::new(),
1046                ),
1047                recovery: Vec::new(),
1048                state_hash: ObjectHash::digest(b"multi-owner initial device state"),
1049            },
1050            Some(membership_authority.clone()),
1051            super::super::store_commit::StoreCommitOperationsInput {
1052                control: None,
1053                device_join_attempts: Vec::new(),
1054                device_join_outcomes: Vec::new(),
1055                device_join_abandonments: Vec::new(),
1056                device_join_cleanup_receipts: Vec::new(),
1057                provider_access_grants: Vec::new(),
1058                provider_access_withdrawals: Vec::new(),
1059                device_registrations: Vec::new(),
1060                circle_controls: vec![reference.clone()],
1061                store_package: None,
1062                circle_packages: &[],
1063            },
1064            &device.device_signer,
1065        )
1066        .expect("sign Store commit");
1067        let first_commit_path = format!(
1068            "{}.json",
1069            super::super::store_commit::commit_semantic_prefix(
1070                commit.candidate_family(),
1071                &device.stream_id.to_string(),
1072                1,
1073                commit.commit_hash(),
1074            )
1075        );
1076        let commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1077            &commit,
1078            first_coord,
1079            exact_logical_object(first_commit_path, &commit.to_bytes()),
1080        )
1081        .expect("reference first Store commit");
1082        let own_access = creation
1083            .access
1084            .iter()
1085            .find(|access| access.leaf.value.recipient_pubkey == author_pubkey)
1086            .expect("author access");
1087        let verified = super::super::circle_ops::VerifiedCircleReference {
1088            reference,
1089            circle_id: creation.circle_id,
1090            control: control.clone(),
1091            local_access: Some(super::super::circle_ops::VerifiedCircleAccess {
1092                leaf: own_access.leaf.clone(),
1093                active: Some(super::super::circle_ops::VerifiedCircleActive {
1094                    roster: creation.resolved_roster(),
1095                    metadata: creation.metadata.clone(),
1096                }),
1097            }),
1098        };
1099        let db = super::super::test_helpers::open_test_db();
1100        let first_commit = commit.clone();
1101        let first_commit_ref = commit_ref.clone();
1102        db.call(move |conn| {
1103            crate::database::Database::record_verified_circle_activations_on(
1104                conn,
1105                &first_commit,
1106                &first_commit_ref,
1107                &[verified],
1108            )
1109        })
1110        .await
1111        .expect("record multi-Owner control");
1112        let circle_id = creation.circle_id.to_string();
1113        let cached_owner = db
1114            .call(move |conn| {
1115                conn.query_row(
1116                    "SELECT owner_pubkey FROM circle_access_cache WHERE circle_id = ?1",
1117                    [circle_id],
1118                    |row| row.get::<_, String>(0),
1119                )
1120                .map_err(crate::database::DbError::from)
1121            })
1122            .await
1123            .expect("read cached access owner");
1124        assert_eq!(cached_owner, author_pubkey);
1125
1126        let mut second_value = control.value.clone();
1127        second_value.access_root = ObjectHash::digest(b"different founder access root");
1128        second_value.signature = keys::sign_hex(&author, &second_value.canonical_bytes()).1;
1129        let second_control = PreparedCircleControl {
1130            coord: second_value.coord(),
1131            bytes: serde_json::to_vec(&second_value).expect("serialize second founder control"),
1132            value: second_value,
1133        };
1134        let second_reference = super::super::store_commit::CircleControlRef::MergeConcurrent {
1135            circle_id: creation.circle_id,
1136            control: second_control.coord.clone(),
1137            head_hash: ObjectHash::digest(b"second-founder-control-head"),
1138            objects: test_activation_objects("second-founder"),
1139        };
1140        let second_coord = super::super::store_commit::StoreCommitCoord::MergeConcurrent {
1141            stream_id: device.stream_id,
1142            sequence: 2,
1143        };
1144        let second_commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1145            store_root_hash,
1146            crate::WriteId::from_generated("second-founder-control-commit".to_string()),
1147            second_coord.clone(),
1148            device.reference,
1149            &device.registration,
1150            super::super::store_commit::StoreCommitOrder::MergeConcurrent {
1151                seq: 2,
1152                predecessor: Some(commit_ref.clone()),
1153                dependencies: BTreeMap::new(),
1154            },
1155            membership,
1156            super::super::store_commit::StoreDeviceStateRef::MergeConcurrent {
1157                frontier: super::super::store_commit::CommitFrontier::MergeConcurrent(
1158                    BTreeMap::from([(device.stream_id, commit_ref.clone())]),
1159                ),
1160                recovery: Vec::new(),
1161                state_hash: ObjectHash::digest(b"multi-owner second device state"),
1162            },
1163            control.value.membership_authority.clone(),
1164            super::super::store_commit::StoreCommitOperationsInput {
1165                control: None,
1166                device_join_attempts: Vec::new(),
1167                device_join_outcomes: Vec::new(),
1168                device_join_abandonments: Vec::new(),
1169                device_join_cleanup_receipts: Vec::new(),
1170                provider_access_grants: Vec::new(),
1171                provider_access_withdrawals: Vec::new(),
1172                device_registrations: Vec::new(),
1173                circle_controls: vec![second_reference.clone()],
1174                store_package: None,
1175                circle_packages: &[],
1176            },
1177            &device.device_signer,
1178        )
1179        .expect("sign second founder Store commit");
1180        let second_commit_path = format!(
1181            "{}.json",
1182            super::super::store_commit::commit_semantic_prefix(
1183                second_commit.candidate_family(),
1184                &device.stream_id.to_string(),
1185                2,
1186                second_commit.commit_hash(),
1187            )
1188        );
1189        let second_commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1190            &second_commit,
1191            second_coord,
1192            exact_logical_object(second_commit_path, &second_commit.to_bytes()),
1193        )
1194        .expect("reference second Store commit");
1195        let error = db
1196            .call(move |conn| {
1197                crate::database::Database::record_verified_circle_activations_on(
1198                    conn,
1199                    &second_commit,
1200                    &second_commit_ref,
1201                    &[super::super::circle_ops::VerifiedCircleReference {
1202                        reference: second_reference,
1203                        circle_id: creation.circle_id,
1204                        control: second_control,
1205                        local_access: None,
1206                    }],
1207                )
1208            })
1209            .await
1210            .expect_err("a Circle cannot accept a second founder control");
1211        assert!(
1212            error.to_string().contains("already has a founder"),
1213            "{error}"
1214        );
1215    }
1216}