Skip to main content

coven_protocol/store_commit/
circle_ack.rs

1use super::validation::{validate_commit_frontier, validate_successor_sequence};
2use super::*;
3
4/// One device's signed acknowledgement of the exact private Circle history it
5/// currently holds, encrypted to the Circle epoch key it names. Store members
6/// outside the Circle observe only the object's shape and timing.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct CircleAckBody {
10    pub store_root_hash: ObjectHash,
11    pub circle_id: CircleId,
12    pub registration: StoreDeviceRegistrationRef,
13    pub sequence: u64,
14    /// The device's accepted Store frontier at staging time — Circle packages
15    /// are activated by Store commits, so Circle coverage IS a Store frontier.
16    pub store_cut: CommitFrontier,
17    /// The exact activated control and epoch the device's live projection
18    /// derives from.
19    pub control: CircleControlCoord,
20    pub epoch_id: CircleEpochId,
21    pub key_fingerprint: KeyFingerprint,
22    /// The exact coverage the device's projection was seeded from: the retained
23    /// bootstrap coverage row (control, activating commit, exact cut, image
24    /// hash). `None` exactly for a founder/source device whose projection never
25    /// came from an image.
26    pub seeded_from: Option<CircleBootstrapCoverageRef>,
27    pub last_sync: String,
28    pub successor: SuccessorLink,
29}
30
31impl SignedBody for CircleAckBody {
32    const DOMAIN: &'static [u8] = CIRCLE_ACK_DOMAIN;
33}
34
35pub type CircleAck = Signed<CircleAckBody>;
36
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct CircleAckRef {
40    pub registration: StoreDeviceRegistrationRef,
41    pub circle_id: CircleId,
42    pub control: CircleControlCoord,
43    pub sequence: u64,
44    pub ack_hash: ObjectHash,
45    pub object: ExactObjectRef,
46}
47
48impl CircleAck {
49    #[allow(clippy::too_many_arguments)]
50    pub fn signed(
51        store_root_hash: ObjectHash,
52        circle_id: CircleId,
53        registration: StoreDeviceRegistrationRef,
54        sequence: u64,
55        store_cut: CommitFrontier,
56        control: CircleControlCoord,
57        epoch_id: CircleEpochId,
58        key_fingerprint: KeyFingerprint,
59        seeded_from: Option<CircleBootstrapCoverageRef>,
60        last_sync: String,
61        successor: SuccessorLink,
62        device_signer: &UserKeypair,
63    ) -> Result<Self, StoreProtocolError> {
64        validate_successor_sequence(sequence, &successor)?;
65        validate_circle_ack_state(&store_cut, &control, &seeded_from, circle_id)?;
66        Ok(Signed::sign(
67            CircleAckBody {
68                store_root_hash,
69                circle_id,
70                registration,
71                sequence,
72                store_cut,
73                control,
74                epoch_id,
75                key_fingerprint,
76                seeded_from,
77                last_sync,
78                successor,
79            },
80            device_signer,
81        ))
82    }
83
84    pub fn ack_hash(&self) -> ObjectHash {
85        self.hash()
86    }
87
88    /// Verify one exact Circle acknowledgement against its expected reference and
89    /// author registration. The successor's stream activation is not recomputed
90    /// here: a Circle-acknowledgement stream's first slot is not carried by the
91    /// author's registration (unlike a Store-acknowledgement stream), so only
92    /// the author that holds it can reproduce the activation. A reader trusts
93    /// the Store commit that named this acknowledgement as the sole activation
94    /// authority, and checks the predecessor/sequence chain for ordering.
95    pub fn parse_at(
96        bytes: &[u8],
97        expected_store_root: &StoreRootRef,
98        expected: &CircleAckRef,
99        author: &StoreDeviceRegistration,
100    ) -> Result<Self, StoreProtocolError> {
101        let ack: Self = crate::objects::decode_protocol_object(bytes)?;
102        ack.require_version()?;
103        crate::objects::verify_store_root(
104            expected_store_root.store_root_hash,
105            ack.store_root_hash,
106        )?;
107        ack.registration.verify_registration(author)?;
108        if ack.registration != expected.registration {
109            return Err(StoreProtocolError::DeviceRegistrationRefMismatch {
110                device_id: expected.registration.device_id.to_string(),
111                expected: expected.registration.registration_hash,
112                actual: ack.registration.registration_hash,
113            });
114        }
115        if ack.circle_id != expected.circle_id {
116            return Err(StoreProtocolError::Malformed(
117                "Circle acknowledgement names another Circle".to_string(),
118            ));
119        }
120        if ack.control != expected.control {
121            return Err(StoreProtocolError::Malformed(
122                "Circle acknowledgement names another control".to_string(),
123            ));
124        }
125        if ack.sequence != expected.sequence {
126            return Err(StoreProtocolError::RelocatedSlot {
127                expected: circle_ack_slot_prefix(
128                    expected.circle_id,
129                    &author.device_id.to_string(),
130                    expected.sequence,
131                ),
132                actual: circle_ack_slot_prefix(
133                    ack.circle_id,
134                    &author.device_id.to_string(),
135                    ack.sequence,
136                ),
137            });
138        }
139        validate_successor_sequence(ack.sequence, &ack.successor)?;
140        validate_circle_ack_state(
141            &ack.store_cut,
142            &ack.control,
143            &ack.seeded_from,
144            ack.circle_id,
145        )?;
146        ack.verify_by(&author.device_signing_pubkey)?;
147        if ack.ack_hash() != expected.ack_hash {
148            return Err(StoreProtocolError::ObjectHashMismatch {
149                expected: expected.ack_hash,
150                actual: ack.ack_hash(),
151            });
152        }
153        Ok(ack)
154    }
155}
156
157fn validate_circle_ack_state(
158    store_cut: &CommitFrontier,
159    control: &CircleControlCoord,
160    seeded_from: &Option<CircleBootstrapCoverageRef>,
161    circle_id: CircleId,
162) -> Result<(), StoreProtocolError> {
163    validate_commit_frontier(store_cut)?;
164    control.validate()?;
165    if let Some(seeded_from) = seeded_from {
166        if seeded_from.circle_id != circle_id {
167            return Err(StoreProtocolError::Malformed(
168                "Circle acknowledgement seed coverage names another Circle".to_string(),
169            ));
170        }
171    }
172    Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn circle_ack_reference_names_its_encryption_control() {
181        let control = CircleControlCoord {
182            device_id: "circle-ack-author".to_string(),
183            stream_id: crate::causal_grants::AuthorStreamId::from_digest(ObjectHash::digest(
184                b"circle-ack-stream",
185            )),
186            author_pubkey: "circle-ack-author-pubkey".to_string(),
187            author_owner_grant: crate::causal_grants::MembershipGrantId::from_test_label(
188                "circle-ack-owner",
189            ),
190            seq: 1,
191            control_hash: ObjectHash::digest(b"circle-ack-control"),
192        };
193        let object = ExactObjectRef::new(
194            crate::objects::ObjectSlot::logical(
195                "circles/ack-test/acknowledgements/device/1.json".to_string(),
196            )
197            .expect("valid acknowledgement slot"),
198            1,
199            ObjectHash::digest(b"ack"),
200        );
201        let reference = CircleAckRef {
202            registration: StoreDeviceRegistrationRef {
203                device_id: ObjectHash::digest(b"circle-ack-device")
204                    .to_string()
205                    .parse()
206                    .expect("digest is a valid device id"),
207                registration_hash: ObjectHash::digest(b"circle-ack-registration"),
208                object: object.clone(),
209            },
210            circle_id: CircleId::from_bytes([1; 16]),
211            control: control.clone(),
212            sequence: 1,
213            ack_hash: ObjectHash::digest(b"circle-ack"),
214            object,
215        };
216
217        let encoded = serde_json::to_value(reference).expect("serialize acknowledgement ref");
218        assert_eq!(
219            encoded.get("control"),
220            Some(&serde_json::to_value(control).expect("serialize control"))
221        );
222    }
223}