1use 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#[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)]
134pub enum CircleInfo {
135 Active {
136 id: CircleId,
137 name: String,
138 role: CircleRole,
139 rotation_required: bool,
144 },
145 Conflicted {
146 id: CircleId,
147 branches: Vec<CircleControlCoord>,
151 },
152 Deleted { id: CircleId },
155}
156
157impl CircleInfo {
158 pub fn id(&self) -> CircleId {
159 match self {
160 Self::Active { id, .. } | Self::Conflicted { id, .. } | Self::Deleted { id } => *id,
161 }
162 }
163
164 pub fn name(&self) -> Option<&str> {
167 match self {
168 Self::Active { name, .. } => Some(name),
169 Self::Conflicted { .. } | Self::Deleted { .. } => None,
170 }
171 }
172
173 pub fn rotation_required(&self) -> bool {
177 matches!(
178 self,
179 Self::Active {
180 rotation_required: true,
181 ..
182 }
183 )
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum CircleState {
191 Active,
194 Inactive,
197 Closing,
200 RotationRequired { removed_members: Vec<String> },
204 ControlConflict { branches: Vec<CircleControlCoord> },
207 Deleted,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Circle {
218 pub id: CircleId,
219 pub name: Option<String>,
220 pub role: Option<CircleRole>,
221 pub state: CircleState,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum CircleCloseSettlement {
229 Responded,
230 Excluded,
231 Pending,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct CircleCloseParticipant {
237 pub device_id: crate::sync::store_commit::StoreDeviceId,
238 pub settlement: CircleCloseSettlement,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct CircleCloseStatus {
245 pub circle_id: CircleId,
246 pub close_id: CircleEpochCloseId,
247 pub participants: Vec<CircleCloseParticipant>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum CirclePublicationBlocked {
255 RotationRequired {
256 circle_id: CircleId,
257 removed_members: Vec<String>,
258 },
259}
260
261impl std::fmt::Display for CirclePublicationBlocked {
262 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 match self {
264 Self::RotationRequired {
265 circle_id,
266 removed_members,
267 } => write!(
268 formatter,
269 "Circle {circle_id} requires rotation: its roster names removed Store members {removed_members:?}"
270 ),
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct CircleMemberInfo {
277 pub pubkey: String,
278 pub role: CircleRole,
279 pub is_self: bool,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283#[serde(transparent)]
284pub struct CircleOperationId(crate::WriteId);
285
286impl CircleOperationId {
287 pub(crate) fn from_write_id(write_id: crate::WriteId) -> Self {
288 Self(write_id)
289 }
290
291 #[cfg(any(test, feature = "test-utils"))]
294 pub fn placeholder(seed: &str) -> Self {
295 Self::from_write_id(crate::WriteId::from_generated(seed.to_string()))
296 }
297
298 pub fn as_str(&self) -> &str {
299 self.0.as_str()
300 }
301
302 pub(crate) fn finalization_write_id(&self) -> crate::WriteId {
303 crate::WriteId::from_generated(
304 crate::sync::store_commit::ObjectHash::digest(
305 &[
306 b"coven.circle-epoch-close-finalization-write.v1\0".as_slice(),
307 self.as_str().as_bytes(),
308 ]
309 .concat(),
310 )
311 .to_string(),
312 )
313 }
314
315 pub(crate) fn cancellation_write_id(&self) -> crate::WriteId {
316 crate::WriteId::from_generated(
317 crate::sync::store_commit::ObjectHash::digest(
318 &[
319 b"coven.circle-epoch-close-cancellation-write.v1\0".as_slice(),
320 self.as_str().as_bytes(),
321 ]
322 .concat(),
323 )
324 .to_string(),
325 )
326 }
327}
328
329impl fmt::Display for CircleOperationId {
330 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331 formatter.write_str(self.as_str())
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
340#[serde(transparent)]
341pub struct CircleEpochCloseId(ObjectHash);
342
343impl CircleEpochCloseId {
344 pub(crate) fn from_operation_id(operation_id: &CircleOperationId) -> Self {
345 Self(ObjectHash::digest(
346 &[
347 b"coven.circle-epoch-close-id.v1\0".as_slice(),
348 operation_id.as_str().as_bytes(),
349 ]
350 .concat(),
351 ))
352 }
353}
354
355impl fmt::Display for CircleEpochCloseId {
356 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357 fmt::Display::fmt(&self.0, formatter)
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum CircleOperationKind {
363 Create,
364 Rename,
365 AddMember,
366 RemoveMember,
367 ResolveControl,
368 Delete,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case", deny_unknown_fields)]
375pub enum CircleOperationBlock {
376 AuthorityLost {
378 grant_id: crate::sync::membership::MembershipGrantId,
379 },
380}
381
382impl std::fmt::Display for CircleOperationBlock {
383 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 match self {
385 Self::AuthorityLost { grant_id } => write!(
386 formatter,
387 "author grant {grant_id} no longer has current Store write authority"
388 ),
389 }
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case", deny_unknown_fields)]
395pub enum CircleOperationState {
396 Pending,
397 WaitingForCloseResponses,
398 Finalizing,
399 Blocked {
400 block: CircleOperationBlock,
401 },
402 Discarding,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct CircleOperationInfo {
410 pub operation_id: CircleOperationId,
411 pub circle_id: CircleId,
412 pub kind: CircleOperationKind,
413 pub state: CircleOperationState,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
418pub enum Audience {
419 Store,
420 Circle(CircleId),
421 Local,
422}
423
424impl Audience {
425 pub fn from_column(value: Option<&str>) -> Result<Self, CircleIdError> {
426 match value {
427 None => Ok(Self::Store),
428 Some("local") => Ok(Self::Local),
429 Some(circle) => circle.parse().map(Self::Circle),
430 }
431 }
432
433 pub fn column_value(&self) -> Option<String> {
434 match self {
435 Self::Store => None,
436 Self::Circle(circle) => Some(circle.to_string()),
437 Self::Local => Some("local".to_string()),
438 }
439 }
440}
441
442macro_rules! generated_hex_id {
443 ($name:ident, $domain:ident) => {
444 #[derive(
445 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
446 )]
447 #[serde(transparent)]
448 pub struct $name([u8; 16]);
449
450 impl $name {
451 pub(crate) fn generate(ids: &dyn crate::id_provider::IdProvider) -> Self {
452 Self(generated_id_bytes(ids, $domain))
453 }
454 }
455
456 impl fmt::Display for $name {
457 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
458 formatter.write_str(&hex::encode(self.0))
459 }
460 }
461 };
462}
463
464generated_hex_id!(CircleEpochId, CIRCLE_EPOCH_ID_GENERATION_DOMAIN);
465generated_hex_id!(AccessLeafId, ACCESS_LEAF_ID_GENERATION_DOMAIN);
466
467pub(crate) fn generated_id_digest(
468 ids: &dyn crate::id_provider::IdProvider,
469 domain: &[u8],
470) -> ObjectHash {
471 let id = ids.new_id();
472 let mut material = Vec::with_capacity(domain.len() + id.len());
473 material.extend_from_slice(domain);
474 material.extend_from_slice(id.as_bytes());
475 ObjectHash::digest(&material)
476}
477
478fn generated_id_bytes(ids: &dyn crate::id_provider::IdProvider, domain: &[u8]) -> [u8; 16] {
479 generated_id_digest(ids, domain).as_bytes()[..16]
480 .try_into()
481 .expect("SHA-256 digest prefix has fixed length")
482}
483
484#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
487pub struct RowRoutingId([u8; 32]);
488
489impl fmt::Debug for RowRoutingId {
490 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491 fmt::Display::fmt(self, formatter)
492 }
493}
494
495impl fmt::Display for RowRoutingId {
496 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
497 formatter.write_str(&hex::encode(self.0))
498 }
499}
500
501impl FromStr for RowRoutingId {
502 type Err = RowRoutingIdError;
503
504 fn from_str(value: &str) -> Result<Self, Self::Err> {
505 if value.len() != 64
506 || value
507 .bytes()
508 .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
509 {
510 return Err(RowRoutingIdError(value.to_string()));
511 }
512 let bytes: [u8; 32] = hex::decode(value)
513 .map_err(|_| RowRoutingIdError(value.to_string()))?
514 .try_into()
515 .map_err(|_| RowRoutingIdError(value.to_string()))?;
516 Ok(Self(bytes))
517 }
518}
519
520impl Serialize for RowRoutingId {
521 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
522 where
523 S: Serializer,
524 {
525 serializer.serialize_str(&self.to_string())
526 }
527}
528
529impl<'de> Deserialize<'de> for RowRoutingId {
530 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
531 where
532 D: Deserializer<'de>,
533 {
534 String::deserialize(deserializer)?
535 .parse()
536 .map_err(serde::de::Error::custom)
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
541#[error("row routing id must be exactly 64 lowercase hexadecimal characters: {0:?}")]
542pub struct RowRoutingIdError(String);
543
544#[derive(Clone)]
545pub(crate) struct RowRoutingKey([u8; 32]);
546
547#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
548pub enum RowRoutingKeyError {
549 #[error("Store keyring has no generation-1 key")]
550 MissingGenerationOne,
551 #[error("Store keyring has more than one generation-1 key")]
552 AmbiguousGenerationOne,
553}
554
555pub(crate) fn derive_row_routing_key(
556 encryption: &EncryptionService,
557 store_root_hash: ObjectHash,
558) -> Result<RowRoutingKey, RowRoutingKeyError> {
559 let mut generation_one = encryption
560 .keyring_entries()
561 .into_iter()
562 .filter_map(|(generation, key)| (generation == 1).then_some(key));
563 let key = generation_one
564 .next()
565 .ok_or(RowRoutingKeyError::MissingGenerationOne)?;
566 if generation_one.next().is_some() {
567 return Err(RowRoutingKeyError::AmbiguousGenerationOne);
568 }
569 let hkdf = Hkdf::<Sha256>::new(Some(ROW_ROUTING_KEY_DOMAIN), &key);
570 let mut derived = [0u8; 32];
571 hkdf.expand(store_root_hash.as_bytes(), &mut derived)
572 .expect("32 bytes is a valid HKDF output length");
573 Ok(RowRoutingKey(derived))
574}
575
576pub(crate) fn row_routing_id(key: &RowRoutingKey, table: &str, row_id: &str) -> RowRoutingId {
577 let mut mac = Hmac::<Sha256>::new_from_slice(&key.0).expect("HMAC accepts a 32-byte key");
578 mac.update(ROW_ROUTING_ID_DOMAIN);
579 mac.update(&(table.len() as u64).to_be_bytes());
580 mac.update(table.as_bytes());
581 mac.update(&(row_id.len() as u64).to_be_bytes());
582 mac.update(row_id.as_bytes());
583 RowRoutingId(mac.finalize().into_bytes().into())
584}
585
586fn encode_base32(bytes: &[u8; 16]) -> String {
587 let mut output = String::with_capacity(CIRCLE_ID_LENGTH);
588 let mut buffer = 0u32;
589 let mut bits = 0u8;
590 for byte in bytes {
591 buffer = (buffer << 8) | u32::from(*byte);
592 bits += 8;
593 while bits >= 5 {
594 bits -= 5;
595 output.push(CIRCLE_ID_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
596 buffer &= (1u32 << bits).wrapping_sub(1);
597 }
598 }
599 if bits != 0 {
600 output.push(CIRCLE_ID_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
601 }
602 output
603}
604
605fn decode_base32(value: &str) -> Result<[u8; 16], CircleIdError> {
606 if value.len() != CIRCLE_ID_LENGTH {
607 return Err(CircleIdError(value.to_string()));
608 }
609 let mut output = Vec::with_capacity(16);
610 let mut buffer = 0u32;
611 let mut bits = 0u8;
612 for byte in value.bytes() {
613 let digit = CIRCLE_ID_ALPHABET
614 .iter()
615 .position(|candidate| *candidate == byte)
616 .ok_or_else(|| CircleIdError(value.to_string()))? as u32;
617 buffer = (buffer << 5) | digit;
618 bits += 5;
619 while bits >= 8 {
620 bits -= 8;
621 output.push(((buffer >> bits) & 0xff) as u8);
622 buffer &= (1u32 << bits).wrapping_sub(1);
623 }
624 }
625 if output.len() != 16 || buffer != 0 {
626 return Err(CircleIdError(value.to_string()));
627 }
628 output
629 .try_into()
630 .map_err(|_| CircleIdError(value.to_string()))
631}
632
633#[cfg(test)]
634mod tests {
635 use std::collections::BTreeMap;
636
637 use super::super::circle_control::{merkle_root_and_proofs, verify_merkle_proof};
638 use super::*;
639 use crate::keys::{self, UserKeypair};
640
641 fn candidate_family(label: &str) -> super::super::store_commit::CandidateFamilyId {
642 super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
643 label.as_bytes(),
644 ))
645 }
646
647 fn exact_object(label: &str, bytes: &[u8]) -> super::super::storage::ExactObjectRef {
648 super::super::storage::ExactObjectRef::new(
649 crate::storage::cloud::ObjectSlot::logical(format!("store-v1/test/{label}.json"))
650 .unwrap(),
651 bytes.len() as u64,
652 ObjectHash::digest(bytes),
653 )
654 }
655
656 fn exact_logical_object(
657 logical_key: String,
658 bytes: &[u8],
659 ) -> super::super::storage::ExactObjectRef {
660 super::super::storage::ExactObjectRef::new(
661 crate::storage::cloud::ObjectSlot::logical(logical_key).unwrap(),
662 bytes.len() as u64,
663 ObjectHash::digest(bytes),
664 )
665 }
666
667 fn test_founder_entry(
668 label: &str,
669 owner: &UserKeypair,
670 membership: super::super::store_commit::GrantStreamAnchor,
671 ) -> super::super::membership::MembershipEntry {
672 super::super::membership::founder_entry(
673 label,
674 owner,
675 crate::sync::test_helpers::test_membership_grant_id(label),
676 "founder",
677 membership,
678 crate::sync::test_helpers::test_founder_provider_admin(label),
679 )
680 }
681
682 fn merge_membership_ref(
683 owner: &UserKeypair,
684 members: &[(String, super::super::membership::MemberRole)],
685 label: &str,
686 ) -> (
687 StoreMembershipStateRef,
688 super::super::membership::MembershipGrantCreationAuthority,
689 ) {
690 let founder = test_founder_entry(
691 label,
692 owner,
693 super::super::store_commit::GrantStreamAnchor::StoreMembership {
694 first_slot: crate::storage::cloud::ObjectSlot::logical(format!(
695 "store-v1/test/{label}/membership/1.json"
696 ))
697 .unwrap(),
698 },
699 );
700 let founder_coord = founder.coord();
701 let mut chain = super::super::membership::MembershipChain::from_entries(vec![founder])
702 .expect("found merge-concurrent membership");
703 for (index, (pubkey, role)) in members.iter().enumerate() {
704 if pubkey == &keys::public_key_hex(owner) {
705 continue;
706 }
707 if role == &super::super::membership::MemberRole::Owner {
708 chain
709 .add_owner_for_test(
710 owner,
711 founder_coord.stream_id,
712 pubkey.clone(),
713 format!("member-{index}"),
714 )
715 .expect("promote merge-concurrent Owner");
716 continue;
717 }
718 let entry = chain
719 .signed_set_member_in_stream(
720 owner,
721 founder_coord.stream_id,
722 pubkey.clone(),
723 None,
724 role.clone(),
725 format!("member-{index}"),
726 )
727 .expect("sign merge-concurrent member");
728 chain
729 .add_entry(entry)
730 .expect("apply merge-concurrent member");
731 }
732 let resolved = match chain.status() {
733 super::super::membership::MembershipStatus::Resolved(resolved) => resolved,
734 super::super::membership::MembershipStatus::Conflict(_) => {
735 panic!("membership fixture must resolve")
736 }
737 };
738 let tip = chain.entries().last().expect("membership tip").coord();
739 let head = super::super::membership::MembershipHeadRef {
740 coord: tip,
741 head_hash: ObjectHash::digest(format!("{label} head").as_bytes()),
742 object: exact_object(&format!("{label}/membership-head"), b"membership head"),
743 };
744 (
745 StoreMembershipStateRef::from_parts(
746 vec![head],
747 Vec::new(),
748 Vec::new(),
749 resolved.state_hash,
750 )
751 .expect("valid merge-concurrent membership reference"),
752 super::super::membership::MembershipGrantCreationAuthority::Entry(founder_coord),
753 )
754 }
755
756 struct MergeDeviceAuthority {
757 registration: super::super::store_commit::StoreDeviceRegistration,
758 reference: super::super::store_commit::StoreDeviceRegistrationRef,
759 device_signer: UserKeypair,
760 stream_id: super::super::membership::AuthorStreamId,
761 }
762
763 fn merge_device_authority(
764 identity: &UserKeypair,
765 store_root_hash: ObjectHash,
766 label: &str,
767 ) -> MergeDeviceAuthority {
768 let root = super::super::store_commit::StoreRootRef {
769 store_root_id: ObjectHash::digest(format!("{label} identity").as_bytes()),
770 store_root_hash,
771 object: exact_object(&format!("{label}/root"), label.as_bytes()),
772 };
773 let slot = |stream: &str| {
774 crate::storage::cloud::ObjectSlot::logical(format!(
775 "store-v1/test/{label}/{stream}/1.json"
776 ))
777 .unwrap()
778 };
779 let registration = super::super::store_commit::StoreDeviceRegistration::signed(
780 root.clone(),
781 super::super::store_commit::StoreDeviceRegistrationOrigin::Founder {
782 creation_id: super::super::store_commit::StoreCreationId::from_nonce(label),
783 },
784 super::super::storage::ProviderDeviceBinding {
785 principal: super::super::storage::ProviderPrincipalId::CustomS3Credential {
786 access_key_id_hash: ObjectHash::digest(label.as_bytes()),
787 },
788 },
789 super::super::store_commit::DeviceStreamAnchor::StoreAnnouncements {
790 first_slot: slot("announcements"),
791 },
792 super::super::store_commit::DeviceStreamAnchor::StoreAcknowledgements {
793 first_slot: slot("acknowledgements"),
794 },
795 super::super::store_commit::DeviceStreamAnchor::StoreSnapshots {
796 first_slot: slot("snapshots"),
797 },
798 identity,
799 )
800 .expect("sign test device registration");
801 let bytes = registration.to_bytes();
802 let reference = super::super::store_commit::StoreDeviceRegistrationRef::from_registration(
803 ®istration,
804 exact_object(&format!("{label}/registration"), &bytes),
805 );
806 let device_signer = registration
807 .device_signer(identity)
808 .expect("derive registered device signer");
809 let stream_id = super::super::store_commit::StreamActivation::device_authorized_stream_id(
810 root.store_root_hash,
811 &reference,
812 super::super::store_commit::StreamAnchorDomain::StoreAnnouncements,
813 );
814 MergeDeviceAuthority {
815 registration,
816 reference,
817 device_signer,
818 stream_id,
819 }
820 }
821
822 fn merge_control_reference(
823 control: &PreparedCircleControl,
824 device: &MergeDeviceAuthority,
825 label: &str,
826 ) -> super::super::store_commit::CircleControlRef {
827 let control_object = exact_object(&format!("{label}/control"), &control.bytes);
828 let head_slot = crate::storage::cloud::ObjectSlot::logical(format!(
829 "store-v1/test/{label}/control-head/1.json"
830 ))
831 .expect("valid test Circle control-head slot");
832 let activation = super::super::store_commit::StreamActivation::grant_authorized(
833 control.value.store_root_hash,
834 device.reference.clone(),
835 control.value.author_grant_id(),
836 super::super::store_commit::GrantStreamAnchor::CircleControl {
837 circle_id: control.value.circle_id,
838 first_slot: head_slot.clone(),
839 },
840 );
841 let head = CircleControlHead::signed(
842 &control.value,
843 control_object.clone(),
844 super::super::store_commit::SuccessorLink {
845 activation: activation.activation_id(),
846 predecessor: None,
847 next_slot: crate::storage::cloud::ObjectSlot::logical(format!(
848 "store-v1/test/{label}/control-head/2.json"
849 ))
850 .expect("valid next test Circle control-head slot"),
851 },
852 &device.device_signer,
853 );
854 let head_bytes = serde_json::to_vec(&head).expect("serialize test Circle control head");
855 let head_object = super::super::storage::ExactObjectRef::new(
856 head_slot,
857 head_bytes.len() as u64,
858 ObjectHash::digest(&head_bytes),
859 );
860 let objects = super::super::store_commit::CircleActivationObjects {
861 control: control_object,
862 close_intent: None,
863 close_outcome: None,
864 close_cancellation: None,
865 roster_entries: BTreeMap::new(),
866 roster_heads: Vec::new(),
867 roster_resolutions: BTreeMap::new(),
868 metadata_entries: BTreeMap::new(),
869 metadata_heads: Vec::new(),
870 access: Vec::new(),
871 };
872 super::super::store_commit::CircleControlRef {
873 circle_id: control.value.circle_id,
874 control: control.coord.clone(),
875 head_hash: head.head_hash(),
876 head_object,
877 objects,
878 }
879 }
880
881 #[test]
882 fn merkle_proofs_verify_for_every_leaf_in_even_and_odd_layers() {
883 for leaf_count in 1..=9 {
884 let leaves = (0..leaf_count)
885 .map(|index| ObjectHash::digest(format!("leaf-{index}").as_bytes()))
886 .collect::<Vec<_>>();
887 let (root, proofs) = merkle_root_and_proofs(&leaves);
888 assert_eq!(proofs.len(), leaves.len());
889 for (index, (leaf, proof)) in leaves.iter().zip(&proofs).enumerate() {
890 assert!(
891 verify_merkle_proof(*leaf, proof, root),
892 "leaf {index} of {leaf_count} failed its canonical proof"
893 );
894 }
895 }
896 }
897
898 #[test]
899 fn founder_payload_is_complete_and_acyclic() {
900 let owner = crate::keys::UserKeypair::generate();
901 let peer = crate::keys::UserKeypair::generate();
902 let owner_pubkey = crate::keys::public_key_hex(&owner);
903 let peer_pubkey = crate::keys::public_key_hex(&peer);
904 let members = vec![
905 (
906 owner_pubkey.clone(),
907 super::super::membership::MemberRole::Owner,
908 ),
909 (
910 peer_pubkey.clone(),
911 super::super::membership::MemberRole::Member,
912 ),
913 ];
914
915 let (membership, membership_authority) =
916 merge_membership_ref(&owner, &members, "founder-circle-merge");
917 let ids = crate::id_provider::SequentialIdProvider::new("founder-circle");
918 let candidate_family = candidate_family("founder-circle");
919 let creation = CircleTransitionDraft::founder(
920 ObjectHash::digest(b"store-root"),
921 candidate_family,
922 "device-a",
923 "Household",
924 "0000000001000-0000-device-a",
925 membership,
926 membership_authority,
927 members.clone(),
928 &ids,
929 &owner,
930 )
931 .expect("construct founder circle");
932
933 assert!(creation.control.verify());
934 assert!(creation.metadata.verify());
935 assert!(creation.roster.verify());
936 assert_eq!(creation.access.len(), 2);
937 for access in &creation.access {
938 assert!(access.leaf.verify(&creation.control, candidate_family));
939 assert!(access.envelope.verify(&creation.control, candidate_family));
940 assert!(access.leaf.verify_envelope(
941 &creation.control,
942 &access.envelope,
943 candidate_family
944 ));
945 assert!(!access.leaf.bytes.windows(64).any(|window| {
946 window == creation.control.coord.control_hash().to_string().as_bytes()
947 }));
948 }
949 assert!(matches!(
950 creation
951 .access
952 .iter()
953 .find(|access| access.leaf.value.recipient_pubkey == owner_pubkey)
954 .unwrap()
955 .leaf
956 .value
957 .disposition,
958 CircleAccessDisposition::Active { .. }
959 ));
960 assert!(matches!(
961 creation
962 .access
963 .iter()
964 .find(|access| access.leaf.value.recipient_pubkey == peer_pubkey)
965 .unwrap()
966 .leaf
967 .value
968 .disposition,
969 CircleAccessDisposition::Inactive
970 ));
971
972 let mut seized = creation.control.value.clone();
973 seized.circle_id = CircleId::from_bytes([0x5a; 16]);
974 seized.signature = keys::sign_hex(&owner, &seized.canonical_bytes()).1;
975 assert!(
976 !seized.verify(),
977 "a founder control must not choose an arbitrary Circle ID"
978 );
979
980 let mut discontinuous = creation.control.value.clone();
981 discontinuous.value.order.seq = 2;
982 discontinuous.signature = keys::sign_hex(&owner, &discontinuous.canonical_bytes()).1;
983 assert!(
984 !discontinuous.verify(),
985 "a control without a predecessor must be genesis"
986 );
987 }
988
989 #[test]
990 fn access_verification_rejects_signed_context_and_proof_substitution() {
991 let owner = crate::keys::UserKeypair::generate();
992 let peer = crate::keys::UserKeypair::generate();
993 let owner_pubkey = crate::keys::public_key_hex(&owner);
994 let peer_pubkey = crate::keys::public_key_hex(&peer);
995 let members = vec![
996 (
997 owner_pubkey.clone(),
998 super::super::membership::MemberRole::Owner,
999 ),
1000 (
1001 peer_pubkey.clone(),
1002 super::super::membership::MemberRole::Member,
1003 ),
1004 ];
1005 let (membership, authority) = merge_membership_ref(&owner, &members, "access-verification");
1006 let ids = crate::id_provider::SequentialIdProvider::new("access-verification");
1007 let candidate_family = candidate_family("access-verification");
1008 let creation = CircleTransitionDraft::founder(
1009 ObjectHash::digest(b"store-root"),
1010 candidate_family,
1011 "device-a",
1012 "Household",
1013 "0000000001000-0000-device-a",
1014 membership,
1015 authority,
1016 members.clone(),
1017 &ids,
1018 &owner,
1019 )
1020 .expect("construct founder circle");
1021
1022 let mut wrong_store = creation.access[0].envelope.clone();
1023 wrong_store.store_root_hash = ObjectHash::digest(b"other-store");
1024
1025 wrong_store.signature = keys::sign_hex(&owner, &wrong_store.canonical_bytes()).1;
1026 assert!(!wrong_store.verify(&creation.control, candidate_family));
1027
1028 let mut wrong_family_envelope = creation.access[0].envelope.clone();
1029 wrong_family_envelope.candidate_family =
1030 super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
1031 b"other access family",
1032 ));
1033 wrong_family_envelope.signature =
1034 keys::sign_hex(&owner, &wrong_family_envelope.canonical_bytes()).1;
1035 assert!(!wrong_family_envelope.verify(&creation.control, candidate_family));
1036
1037 let mut wrong_family_leaf = creation.access[0].leaf.clone();
1038 wrong_family_leaf.value.candidate_family =
1039 super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
1040 b"other leaf family",
1041 ));
1042 wrong_family_leaf.value.signature =
1043 keys::sign_hex(&owner, &wrong_family_leaf.value.canonical_bytes()).1;
1044 assert!(!wrong_family_leaf.verify(&creation.control, candidate_family));
1045
1046 let mut non_owner = creation.access[0].envelope.clone();
1047 non_owner.owner_pubkey = peer_pubkey;
1048 non_owner.signature = keys::sign_hex(&peer, &non_owner.canonical_bytes()).1;
1049 assert!(!non_owner.verify(&creation.control, candidate_family));
1050
1051 let mut substituted_proof = creation.access[0].envelope.clone();
1052 substituted_proof.proof = creation.access[1].envelope.proof.clone();
1053 substituted_proof.signature =
1054 keys::sign_hex(&owner, &substituted_proof.canonical_bytes()).1;
1055 assert!(!substituted_proof.verify(&creation.control, candidate_family));
1056
1057 let mut substituted_leaf_id = creation.access[0].envelope.clone();
1058 substituted_leaf_id.leaf_id = creation.access[1].leaf.value.leaf_id;
1059 substituted_leaf_id.signature =
1060 keys::sign_hex(&owner, &substituted_leaf_id.canonical_bytes()).1;
1061 assert!(substituted_leaf_id.verify(&creation.control, candidate_family));
1062 assert!(!creation.access[0].leaf.verify_envelope(
1063 &creation.control,
1064 &substituted_leaf_id,
1065 candidate_family,
1066 ));
1067
1068 let mut wrong_membership_leaf = creation.access[0].leaf.value.clone();
1069 wrong_membership_leaf.store_membership =
1070 merge_membership_ref(&owner, &members, "wrong-membership-leaf").0;
1071 wrong_membership_leaf.signature =
1072 keys::sign_hex(&owner, &wrong_membership_leaf.canonical_bytes()).1;
1073 let recipient_key =
1074 keys::ed25519_to_x25519_public_key(&owner.public_key()).expect("convert recipient key");
1075 let bytes = keys::seal_box_encrypt(
1076 &serde_json::to_vec(&wrong_membership_leaf).expect("serialize forged leaf"),
1077 &recipient_key,
1078 );
1079 let wrong_membership_leaf = PreparedAccessLeaf {
1080 leaf_hash: ObjectHash::digest(&bytes),
1081 bytes,
1082 value: wrong_membership_leaf,
1083 };
1084 assert!(!wrong_membership_leaf.verify(&creation.control, candidate_family));
1085
1086 let mut wrong_keyring_leaf = creation
1087 .access
1088 .iter()
1089 .find(|access| {
1090 matches!(
1091 &access.leaf.value.disposition,
1092 CircleAccessDisposition::Active { .. }
1093 )
1094 })
1095 .expect("founder access")
1096 .leaf
1097 .value
1098 .clone();
1099 let CircleAccessDisposition::Active { keyring, .. } = &mut wrong_keyring_leaf.disposition
1100 else {
1101 panic!("founder access must be active")
1102 };
1103 *keyring = crate::encryption::MasterKeyring::generate().to_serialized();
1104 wrong_keyring_leaf.signature =
1105 keys::sign_hex(&owner, &wrong_keyring_leaf.canonical_bytes()).1;
1106 let bytes = keys::seal_box_encrypt(
1107 &serde_json::to_vec(&wrong_keyring_leaf).expect("serialize wrong-keyring leaf"),
1108 &recipient_key,
1109 );
1110 let wrong_keyring_leaf = PreparedAccessLeaf {
1111 leaf_hash: ObjectHash::digest(&bytes),
1112 bytes,
1113 value: wrong_keyring_leaf,
1114 };
1115 assert!(!wrong_keyring_leaf.verify(&creation.control, candidate_family));
1116 }
1117
1118 #[test]
1119 fn circle_id_round_trips_only_its_canonical_lowercase_base32() {
1120 let id = CircleId::from_bytes([0; 16]);
1121 let encoded = id.to_string();
1122 assert_eq!(encoded.len(), CIRCLE_ID_LENGTH);
1123 assert_eq!(encoded.parse::<CircleId>().unwrap(), id);
1124 assert!(encoded.to_uppercase().parse::<CircleId>().is_err());
1125 assert!("local".parse::<CircleId>().is_err());
1126 assert!(format!("{}b", &encoded[..25]).parse::<CircleId>().is_err());
1127 }
1128
1129 #[test]
1130 fn recipient_slot_rejects_the_ed25519_identity_point() {
1131 let local = UserKeypair::generate();
1132 let mut identity = [0; keys::SIGN_PUBLICKEYBYTES];
1133 identity[0] = 1;
1134 let recipient = hex::encode(identity);
1135
1136 assert_eq!(
1137 recipient_slot_with_peer(&local, &recipient, CircleId::from_bytes([9; 16])),
1138 Err(CircleTransitionError::InvalidRecipient(recipient))
1139 );
1140 }
1141
1142 #[test]
1143 fn row_routing_id_is_stable_across_store_key_rotation() {
1144 let root = ObjectHash::digest(b"store-root");
1145 let before = EncryptionService::from_key([1u8; 32]);
1146 let after = before
1147 .with_appended_generation(2, [2u8; 32])
1148 .expect("append generation");
1149 let before_id = row_routing_id(
1150 &derive_row_routing_key(&before, root).unwrap(),
1151 "accounts",
1152 "row-1",
1153 );
1154 let after_id = row_routing_id(
1155 &derive_row_routing_key(&after, root).unwrap(),
1156 "accounts",
1157 "row-1",
1158 );
1159 assert_eq!(before_id, after_id);
1160 assert_ne!(
1161 before_id,
1162 row_routing_id(
1163 &derive_row_routing_key(&after, root).unwrap(),
1164 "accounts",
1165 "row-2",
1166 )
1167 );
1168 }
1169
1170 #[test]
1171 fn row_routing_key_requires_exactly_one_generation_one_key() {
1172 let root = ObjectHash::digest(b"store-root");
1173 let missing = EncryptionService::from_key_at_generation(2, [2u8; 32]);
1174 assert!(matches!(
1175 derive_row_routing_key(&missing, root),
1176 Err(RowRoutingKeyError::MissingGenerationOne)
1177 ));
1178
1179 let ambiguous = EncryptionService::from_keyring([(1, [1u8; 32]), (1, [2u8; 32])])
1180 .expect("build forked generation one");
1181 assert!(matches!(
1182 derive_row_routing_key(&ambiguous, root),
1183 Err(RowRoutingKeyError::AmbiguousGenerationOne)
1184 ));
1185 }
1186
1187 #[tokio::test]
1188 async fn control_history_caches_the_verified_access_owner_and_rejects_second_genesis() {
1189 let author = crate::keys::UserKeypair::generate();
1190 let author_pubkey = crate::keys::public_key_hex(&author);
1191 let earlier_owner = loop {
1192 let candidate = crate::keys::UserKeypair::generate();
1193 if crate::keys::public_key_hex(&candidate) < author_pubkey {
1194 break candidate;
1195 }
1196 };
1197 let earlier_owner_pubkey = crate::keys::public_key_hex(&earlier_owner);
1198 let members = vec![
1199 (
1200 author_pubkey.clone(),
1201 super::super::membership::MemberRole::Owner,
1202 ),
1203 (
1204 earlier_owner_pubkey.clone(),
1205 super::super::membership::MemberRole::Owner,
1206 ),
1207 ];
1208 let store_root_hash = ObjectHash::digest(b"multi-owner-store-root");
1209 let (membership, membership_authority) =
1210 merge_membership_ref(&author, &members, "multi-owner-control");
1211 let device = merge_device_authority(&author, store_root_hash, "multi-owner-device");
1212 let ids = crate::id_provider::SequentialIdProvider::new("multi-owner-control");
1213 let operation_id = crate::WriteId::from_generated("multi-owner-control-commit".to_string());
1214 let order = super::super::store_commit::StoreCommitOrder {
1215 seq: 1,
1216 predecessor: None,
1217 dependencies: BTreeMap::new(),
1218 };
1219 let candidate_family = super::super::store_commit::CandidateFamilyId::derive(
1220 store_root_hash,
1221 &device.reference,
1222 &operation_id,
1223 &order,
1224 );
1225 let creation = CircleTransitionDraft::founder(
1226 store_root_hash,
1227 candidate_family,
1228 &device.reference.device_id.to_string(),
1229 "Household",
1230 "0000000001000-0000-device-a",
1231 membership.clone(),
1232 membership_authority.clone(),
1233 members,
1234 &ids,
1235 &author,
1236 )
1237 .expect("construct founder circle");
1238 let mut control = creation.control.value.clone();
1239 let active_epoch = control
1240 .value
1241 .state
1242 .active_epoch_mut()
1243 .expect("test control has an active epoch");
1244 active_epoch.common.owners = vec![earlier_owner_pubkey, author_pubkey.clone()];
1245 active_epoch.common.owners.sort();
1246 assert_ne!(active_epoch.common.owners[0], control.author_pubkey);
1247 control.signature = keys::sign_hex(&author, &control.canonical_bytes()).1;
1248 let control = PreparedCircleControl {
1249 coord: control.coord(),
1250 bytes: serde_json::to_vec(&control).expect("serialize control"),
1251 value: control,
1252 };
1253 let reference = merge_control_reference(&control, &device, "multi-owner");
1254 let first_coord = super::super::store_commit::StoreCommitCoord {
1255 stream_id: device.stream_id,
1256 sequence: 1,
1257 };
1258 let commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1259 store_root_hash,
1260 operation_id,
1261 first_coord.clone(),
1262 device.reference.clone(),
1263 &device.registration,
1264 order,
1265 membership.clone(),
1266 super::super::store_commit::StoreDeviceStateRef::from_resolved(
1267 super::super::store_commit::CommitFrontier(BTreeMap::new()),
1268 &super::super::store_commit::ResolvedStoreDeviceState {
1269 devices: BTreeMap::new(),
1270 recovery: Vec::new(),
1271 state_hash: ObjectHash::digest(b"multi-owner initial device state"),
1272 },
1273 )
1274 .expect("bind initial device state"),
1275 super::super::store_commit::StoreOperationMembershipAuthority {
1276 predecessor: membership_authority.clone(),
1277 },
1278 super::super::store_commit::StoreCommitOperationsInput {
1279 acknowledgement: None,
1280 circle_acknowledgements: Vec::new(),
1281 control: None,
1282 device_join_attempt_decisions: Vec::new(),
1283 device_join_outcomes: Vec::new(),
1284 device_join_cleanup_receipts: Vec::new(),
1285 provider_access_grants: Vec::new(),
1286 provider_access_withdrawals: Vec::new(),
1287 device_registrations: Vec::new(),
1288 device_exclusion_proposals: Vec::new(),
1289 device_exclusion_outcomes: Vec::new(),
1290 stream_activations: Vec::new(),
1291 circle_controls: vec![reference.clone()],
1292 store_package: None,
1293 circle_packages: &[],
1294 },
1295 &device.device_signer,
1296 )
1297 .expect("sign Store commit");
1298 let first_commit_path = format!(
1299 "{}.json",
1300 super::super::store_commit::commit_semantic_prefix(
1301 commit.candidate_family(),
1302 &device.stream_id.to_string(),
1303 1,
1304 commit.commit_hash(),
1305 )
1306 );
1307 let commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1308 &commit,
1309 first_coord,
1310 exact_logical_object(first_commit_path, &commit.to_bytes()),
1311 )
1312 .expect("reference first Store commit");
1313 let own_access = creation
1314 .access
1315 .iter()
1316 .find(|access| access.leaf.value.recipient_pubkey == author_pubkey)
1317 .expect("author access");
1318 let verified = crate::sync::store::VerifiedCircleReference {
1319 reference,
1320 circle_id: creation.circle_id,
1321 control: control.clone(),
1322 local_access: Some(crate::sync::store::VerifiedCircleAccess {
1323 envelope: own_access.envelope.clone(),
1324 leaf: own_access.leaf.clone(),
1325 active: Some(crate::sync::store::VerifiedCircleActive {
1326 roster: creation.roster.clone(),
1327 metadata: creation.metadata.clone(),
1328 }),
1329 }),
1330 };
1331 let db = super::super::test_helpers::open_test_db();
1332 let first_commit = commit.clone();
1333 let first_commit_ref = commit_ref.clone();
1334 db.call(move |conn| {
1335 crate::sync::store::record_verified_circle_activations_for_test(
1336 conn,
1337 &first_commit,
1338 &first_commit_ref,
1339 &[verified],
1340 )
1341 })
1342 .await
1343 .expect("record multi-Owner control");
1344 let circle_id = creation.circle_id.to_string();
1345 let cached_owner = db
1346 .call(move |conn| {
1347 conn.query_row(
1348 "SELECT owner_pubkey FROM circle_access_cache WHERE circle_id = ?1",
1349 [circle_id],
1350 |row| row.get::<_, String>(0),
1351 )
1352 .map_err(crate::database::DbError::from)
1353 })
1354 .await
1355 .expect("read cached access owner");
1356 assert_eq!(cached_owner, author_pubkey);
1357 db.call(|conn| {
1358 conn.execute("DELETE FROM circle_metadata_cache", [])
1359 .map_err(crate::database::DbError::from)?;
1360 conn.execute("DELETE FROM circle_roster_cache", [])
1361 .map_err(crate::database::DbError::from)?;
1362 conn.execute("DELETE FROM circle_access_cache", [])
1363 .map(|_| ())
1364 .map_err(crate::database::DbError::from)
1365 })
1366 .await
1367 .expect("remove historical Circle projections");
1368 let circles = crate::sync::store::StoreDatabase::new(&db)
1369 .get_circles(
1370 &author_pubkey,
1371 std::collections::BTreeSet::from([author_pubkey.clone()]),
1372 )
1373 .await
1374 .expect("list Circle from its derived current state");
1375 assert_eq!(
1376 circles,
1377 vec![CircleInfo::Active {
1378 id: creation.circle_id,
1379 name: creation.metadata.name.clone(),
1380 role: CircleRole::Owner,
1381 rotation_required: false,
1382 }]
1383 );
1384 let (publication_encryption, publication_fingerprint) =
1385 crate::sync::store::StoreDatabase::new(&db)
1386 .circle_publication_context(creation.circle_id, control.coord.clone())
1387 .await
1388 .expect("load publication authority from derived current state");
1389 assert_eq!(
1390 publication_encryption.seal_key_fingerprint(),
1391 publication_fingerprint
1392 );
1393 assert_eq!(publication_fingerprint, control.value.key_fingerprint());
1394
1395 let mut second_value = control.value.clone();
1396 let active_epoch = second_value
1397 .value
1398 .state
1399 .active_epoch_mut()
1400 .expect("test control has an active epoch");
1401 active_epoch.common.access_root = ObjectHash::digest(b"different founder access root");
1402 second_value.signature = keys::sign_hex(&author, &second_value.canonical_bytes()).1;
1403 let second_control = PreparedCircleControl {
1404 coord: second_value.coord(),
1405 bytes: serde_json::to_vec(&second_value).expect("serialize second founder control"),
1406 value: second_value,
1407 };
1408 let second_reference = merge_control_reference(&second_control, &device, "second-founder");
1409 let second_coord = super::super::store_commit::StoreCommitCoord {
1410 stream_id: device.stream_id,
1411 sequence: 2,
1412 };
1413 let second_commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1414 store_root_hash,
1415 crate::WriteId::from_generated("second-founder-control-commit".to_string()),
1416 second_coord.clone(),
1417 device.reference,
1418 &device.registration,
1419 super::super::store_commit::StoreCommitOrder {
1420 seq: 2,
1421 predecessor: Some(commit_ref.clone()),
1422 dependencies: BTreeMap::new(),
1423 },
1424 membership,
1425 super::super::store_commit::StoreDeviceStateRef::from_resolved(
1426 super::super::store_commit::CommitFrontier(BTreeMap::from([(
1427 device.stream_id,
1428 commit_ref.clone(),
1429 )])),
1430 &super::super::store_commit::ResolvedStoreDeviceState {
1431 devices: BTreeMap::new(),
1432 recovery: Vec::new(),
1433 state_hash: ObjectHash::digest(b"multi-owner second device state"),
1434 },
1435 )
1436 .expect("bind second device state"),
1437 super::super::store_commit::StoreOperationMembershipAuthority {
1438 predecessor: control.value.membership_authority().clone(),
1439 },
1440 super::super::store_commit::StoreCommitOperationsInput {
1441 acknowledgement: None,
1442 circle_acknowledgements: Vec::new(),
1443 control: None,
1444 device_join_attempt_decisions: Vec::new(),
1445 device_join_outcomes: Vec::new(),
1446 device_join_cleanup_receipts: Vec::new(),
1447 provider_access_grants: Vec::new(),
1448 provider_access_withdrawals: Vec::new(),
1449 device_registrations: Vec::new(),
1450 device_exclusion_proposals: Vec::new(),
1451 device_exclusion_outcomes: Vec::new(),
1452 stream_activations: Vec::new(),
1453 circle_controls: vec![second_reference.clone()],
1454 store_package: None,
1455 circle_packages: &[],
1456 },
1457 &device.device_signer,
1458 )
1459 .expect("sign second founder Store commit");
1460 let second_commit_path = format!(
1461 "{}.json",
1462 super::super::store_commit::commit_semantic_prefix(
1463 second_commit.candidate_family(),
1464 &device.stream_id.to_string(),
1465 2,
1466 second_commit.commit_hash(),
1467 )
1468 );
1469 let second_commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1470 &second_commit,
1471 second_coord,
1472 exact_logical_object(second_commit_path, &second_commit.to_bytes()),
1473 )
1474 .expect("reference second Store commit");
1475 let error = db
1476 .call(move |conn| {
1477 crate::sync::store::record_verified_circle_activations_for_test(
1478 conn,
1479 &second_commit,
1480 &second_commit_ref,
1481 &[crate::sync::store::VerifiedCircleReference {
1482 reference: second_reference,
1483 circle_id: creation.circle_id,
1484 control: second_control,
1485 local_access: None,
1486 }],
1487 )
1488 })
1489 .await
1490 .expect_err("a Circle cannot accept a second founder control");
1491 assert!(
1492 error.to_string().contains("already has a founder"),
1493 "{error}"
1494 );
1495 }
1496}