Skip to main content

coven_protocol/store_commit/
validation.rs

1use super::*;
2
3#[derive(Debug, thiserror::Error)]
4pub enum StoreProtocolError {
5    #[error("object hash must be exactly 64 lowercase hexadecimal characters: {0:?}")]
6    InvalidObjectHash(String),
7    #[error("unsupported Store protocol version {0}")]
8    UnsupportedVersion(u32),
9    #[error("malformed Store protocol object: {0}")]
10    Malformed(String),
11    #[error("Store protocol JSON: {0}")]
12    Json(#[from] serde_json::Error),
13    #[error("Store protocol object storage shape: {0}")]
14    Storage(#[from] crate::objects::StorageError),
15    #[error("Store protocol Circle control coordinate: {0}")]
16    CircleControlCoord(#[from] crate::circle_control::CircleControlCoordError),
17    #[error("Store provider probe: {0}")]
18    ProviderProbe(#[source] Box<crate::provider::ProviderProbeError>),
19    #[error("invalid author stream id: {0}")]
20    AuthorStreamId(#[from] crate::causal_grants::AuthorStreamIdParseError),
21    #[error("Store protocol signature is invalid")]
22    InvalidSignature,
23    #[error("Owner promotion evidence does not match its exact Store authority")]
24    OwnerPromotionMismatch,
25    #[error("Store protocol object is in slot {actual:?}, expected {expected:?}")]
26    RelocatedSlot { expected: String, actual: String },
27    #[error("Store package names key {actual:?}, expected {expected:?}")]
28    RelocatedPackage { expected: String, actual: String },
29    #[error("candidate object names key {actual:?}, expected {expected:?}")]
30    RelocatedCandidateObject { expected: String, actual: String },
31    #[error("Store protocol root hash is {actual}, expected {expected}")]
32    StoreRootMismatch {
33        expected: ObjectHash,
34        actual: ObjectHash,
35    },
36    #[error("Store protocol root id is {actual}, expected {expected}")]
37    StoreRootIdMismatch {
38        expected: ObjectHash,
39        actual: ObjectHash,
40    },
41    #[error("Store id is {actual:?}, expected {expected:?}")]
42    StoreMismatch { expected: String, actual: String },
43    #[error("founder is {actual:?}, expected {expected:?}")]
44    FounderMismatch { expected: String, actual: String },
45    #[error("store protocol root has an invalid founder membership entry")]
46    InvalidFounder,
47    #[error("Store sync-routing hash is {actual}, expected {expected}")]
48    SyncRoutingMismatch {
49        expected: ObjectHash,
50        actual: ObjectHash,
51    },
52    #[error("Store Merge membership control is invalid or signed by a different device")]
53    InvalidMergeMembershipControl,
54    #[error("Store batch has no Store package, circle package, or control")]
55    EmptyBatch,
56    #[error("Store batch has no Store package")]
57    MissingStorePackage,
58    #[error("Store batch repeats Store device registration {device_id:?}")]
59    DuplicateDeviceRegistration { device_id: String },
60    #[error("Store device registration {device_id:?} has hash {actual}, expected {expected}")]
61    DeviceRegistrationRefMismatch {
62        device_id: String,
63        expected: ObjectHash,
64        actual: ObjectHash,
65    },
66    #[error("device join attempt fields do not name one exact registration lifecycle")]
67    JoinAttemptMismatch,
68    #[error("device readiness proof differs from its exact attempt, registration, or initial acknowledgement")]
69    DeviceReadinessMismatch,
70    #[error("device join outcome differs from its exact attempt or closed outcome variant")]
71    JoinOutcomeMismatch,
72    #[error("provider access activation contains duplicate or contradictory exact authority")]
73    ProviderAccessMismatch,
74    #[error("Owner recovery node differs from its exact registration lifecycle")]
75    OwnerRecoveryMismatch,
76    #[error("Store device state differs from its signed predecessor state")]
77    DeviceStateMismatch,
78    #[error("Store batch has no package for circle {0}")]
79    MissingCirclePackage(CircleId),
80    #[error("Store batch has more than one package for circle {0}")]
81    DuplicateCirclePackage(CircleId),
82    #[error("Store batch has more than one control for circle {0}")]
83    DuplicateCircleControl(CircleId),
84    #[error("circle control coordinate is invalid")]
85    InvalidCircleControlCoord,
86    #[error("circle {circle_id} package is at {actual:?}, expected {expected:?}")]
87    RelocatedCirclePackage {
88        circle_id: CircleId,
89        expected: String,
90        actual: String,
91    },
92    #[error("Store commit sequence must start at 1, got {0}")]
93    InvalidSequence(u64),
94    #[error("Store commit sequence 1 must not name a predecessor")]
95    UnexpectedPredecessor,
96    #[error("Store commit after sequence 1 must name its predecessor hash")]
97    MissingPredecessor,
98    #[error("Store acknowledgement sequence must start at 1, got {0}")]
99    InvalidAckSequence(u64),
100    #[error("Store acknowledgement sequence 1 must not name a predecessor object")]
101    UnexpectedAckPredecessor,
102    #[error("Store acknowledgement after sequence 1 must name its predecessor object")]
103    MissingAckPredecessor,
104    #[error(
105        "invalid membership coordinate {author}/{grant}/{stream_id}/{seq} with entry hash {entry_hash}"
106    )]
107    InvalidMembershipCoordinate {
108        author: String,
109        grant: String,
110        stream_id: String,
111        seq: u64,
112        entry_hash: String,
113    },
114    #[error("Store package length exceeds the platform address space")]
115    PackageTooLarge,
116    #[error("Store package length is {actual}, expected {expected}")]
117    PackageLengthMismatch { expected: u64, actual: u64 },
118    #[error("Store package hash is {actual}, expected {expected}")]
119    PackageHashMismatch {
120        expected: ObjectHash,
121        actual: ObjectHash,
122    },
123    #[error("Store object hash is {actual}, expected {expected}")]
124    ObjectHashMismatch {
125        expected: ObjectHash,
126        actual: ObjectHash,
127    },
128}
129
130#[derive(Clone, Debug)]
131#[doc(hidden)]
132pub struct VerifiedStoreBatchCommit {
133    reference: StoreBatchCommitRef,
134    // Verification fixes these immutable records. History checkpoints and
135    // publication futures share them without copying their bodies on the stack.
136    value: std::sync::Arc<StoreBatchCommit>,
137    author: std::sync::Arc<StoreDeviceRegistration>,
138}
139
140fn parse_store_batch_commit(
141    bytes: &[u8],
142    expected_store_root_hash: ObjectHash,
143    expected_coord: &StoreCommitCoord,
144    author: &StoreDeviceRegistration,
145) -> Result<StoreBatchCommit, StoreProtocolError> {
146    let commit: StoreBatchCommit = crate::objects::decode_protocol_object(bytes)?;
147    commit.verify_at(expected_store_root_hash, expected_coord, author)?;
148    Ok(commit)
149}
150
151impl VerifiedStoreBatchCommit {
152    pub fn parse_prepared(
153        bytes: &[u8],
154        store_root_hash: ObjectHash,
155        coord: StoreCommitCoord,
156        object: ExactObjectRef,
157        author: &StoreDeviceRegistration,
158    ) -> Result<Self, StoreProtocolError> {
159        let value = parse_store_batch_commit(bytes, store_root_hash, &coord, author)?;
160        let reference = StoreBatchCommitRef::from_commit(&value, coord, object)?;
161        Ok(Self {
162            reference,
163            value: std::sync::Arc::new(value),
164            author: std::sync::Arc::new(author.clone()),
165        })
166    }
167
168    pub fn parse(
169        bytes: &[u8],
170        store_root_hash: ObjectHash,
171        reference: &StoreBatchCommitRef,
172        author: &StoreDeviceRegistration,
173    ) -> Result<Self, StoreProtocolError> {
174        let value = parse_store_batch_commit(bytes, store_root_hash, &reference.coord, author)?;
175        reference.verify_commit(&value)?;
176        Ok(Self {
177            reference: reference.clone(),
178            value: std::sync::Arc::new(value),
179            author: std::sync::Arc::new(author.clone()),
180        })
181    }
182
183    pub fn store_root_hash(&self) -> ObjectHash {
184        self.value.store_root_hash
185    }
186
187    pub fn reference(&self) -> &StoreBatchCommitRef {
188        &self.reference
189    }
190
191    pub fn value(&self) -> &StoreBatchCommit {
192        &self.value
193    }
194
195    pub fn author(&self) -> &StoreDeviceRegistration {
196        &self.author
197    }
198}
199
200impl std::ops::Deref for VerifiedStoreBatchCommit {
201    type Target = StoreBatchCommit;
202
203    fn deref(&self) -> &Self::Target {
204        self.value()
205    }
206}
207
208pub fn store_protocol_root_logical_key() -> &'static str {
209    STORE_PROTOCOL_ROOT_SEMANTIC_PATH
210}
211
212pub fn circle_access_leaf_semantic_prefix(
213    circle_id: CircleId,
214    family: CandidateFamilyId,
215    owner_pubkey: &str,
216    epoch_id: CircleEpochId,
217    recipient_slot: &str,
218    leaf_id: AccessLeafId,
219) -> String {
220    format!(
221        "circles/{circle_id}/candidates/{}/access-leaves/{owner_pubkey}/{epoch_id}/{recipient_slot}/{leaf_id}",
222        family.as_hash(),
223    )
224}
225
226pub fn circle_access_envelope_semantic_prefix(
227    circle_id: CircleId,
228    family: CandidateFamilyId,
229    owner_pubkey: &str,
230    recipient_slot: &str,
231    control_hash: ObjectHash,
232) -> String {
233    format!(
234        "circles/{circle_id}/candidates/{}/access-envelopes/{owner_pubkey}/{recipient_slot}/{control_hash}",
235        family.as_hash(),
236    )
237}
238
239pub fn device_join_abandonment_semantic_prefix(attempt_id: DeviceJoinAttemptId) -> String {
240    format!("{STORE_DEVICE_JOIN_ABANDONMENT_PREFIX}{attempt_id}")
241}
242
243pub fn device_join_cleanup_receipt_semantic_prefix(attempt_id: DeviceJoinAttemptId) -> String {
244    format!("{STORE_DEVICE_JOIN_CLEANUP_RECEIPT_PREFIX}{attempt_id}")
245}
246
247pub fn device_exclusion_proposal_semantic_prefix(
248    target: StoreDeviceId,
249    proposal_id: StoreDeviceExclusionProposalId,
250    proposal_hash: ObjectHash,
251) -> String {
252    format!("{STORE_DEVICE_EXCLUSION_PROPOSAL_PREFIX}{target}/{proposal_id}/{proposal_hash}")
253}
254
255pub fn device_exclusion_outcome_semantic_prefix(
256    target: StoreDeviceId,
257    proposal_id: StoreDeviceExclusionProposalId,
258) -> String {
259    format!("{STORE_DEVICE_EXCLUSION_OUTCOME_PREFIX}{target}/{proposal_id}")
260}
261
262pub fn provider_access_grant_semantic_prefix(
263    grant_id: &crate::provider::ProviderAccessGrantId,
264) -> String {
265    format!("{STORE_PROVIDER_ACCESS_GRANT_PREFIX}{}", grant_id.0)
266}
267
268pub fn owner_recovery_semantic_prefix(
269    owner_pubkey: &str,
270    owner_grant: MembershipGrantId,
271    sequence: u64,
272) -> String {
273    format!("{STORE_OWNER_RECOVERY_PREFIX}{owner_pubkey}/{owner_grant}/{sequence}")
274}
275
276pub fn package_semantic_prefix(
277    family: CandidateFamilyId,
278    device_id: &str,
279    seq: u64,
280    package_hash: ObjectHash,
281) -> String {
282    format!(
283        "{STORE_CANDIDATE_PREFIX}{}/packages/{device_id}/{seq}/{package_hash}",
284        family.as_hash()
285    )
286}
287
288pub fn circle_package_semantic_prefix(
289    circle_id: CircleId,
290    family: CandidateFamilyId,
291    device_id: &str,
292    seq: u64,
293    package_hash: ObjectHash,
294) -> String {
295    format!(
296        "circles/{circle_id}/candidates/{}/packages/{device_id}/{seq}/{package_hash}",
297        family.as_hash()
298    )
299}
300
301pub fn circle_bootstrap_image_semantic_prefix(
302    circle_id: CircleId,
303    family: CandidateFamilyId,
304    owner_pubkey: &str,
305    epoch_id: CircleEpochId,
306    recipient_slot: &str,
307    image_hash: ObjectHash,
308) -> String {
309    format!(
310        "circles/{circle_id}/candidates/{}/bootstraps/{owner_pubkey}/{epoch_id}/{recipient_slot}/{image_hash}",
311        family.as_hash(),
312    )
313}
314
315pub(crate) fn commit_slot_prefix(device_id: &str, seq: u64) -> String {
316    format!("{STORE_CANDIDATE_PREFIX}*/commits/{device_id}/{seq}")
317}
318
319pub fn commit_semantic_prefix(
320    family: CandidateFamilyId,
321    device_id: &str,
322    seq: u64,
323    commit_hash: ObjectHash,
324) -> String {
325    format!(
326        "{STORE_CANDIDATE_PREFIX}{}/commits/{device_id}/{seq}/{commit_hash}",
327        family.as_hash()
328    )
329}
330
331pub fn semantic_prefix_from_exact_object(
332    object: &ExactObjectRef,
333    extension: &str,
334) -> Result<String, StoreProtocolError> {
335    object
336        .slot()
337        .logical_key()
338        .strip_suffix(extension)
339        .map(str::to_string)
340        .ok_or_else(|| StoreProtocolError::RelocatedSlot {
341            expected: format!("candidate object ending in {extension}"),
342            actual: object.slot().logical_key().to_string(),
343        })
344}
345
346pub(crate) fn registration_slot_prefix(device_id: &str) -> String {
347    format!("{STORE_DEVICE_REGISTRATION_PREFIX}{device_id}")
348}
349
350pub fn registration_semantic_prefix(device_id: &str) -> String {
351    registration_slot_prefix(device_id)
352}
353
354pub fn founder_registration_semantic_prefix(creation_id: StoreCreationId) -> String {
355    format!("store-v1/devices/founder/{creation_id}/registration")
356}
357
358pub fn founder_membership_head_semantic_prefix(creation_id: StoreCreationId) -> String {
359    format!("{STORE_MEMBERSHIP_HEAD_PREFIX}founder/{creation_id}/1")
360}
361
362pub fn ack_slot_prefix(device_id: &str, revision: u64) -> String {
363    format!("{STORE_ACK_PREFIX}{device_id}/{revision}")
364}
365
366pub fn circle_ack_slot_prefix(circle_id: CircleId, device_id: &str, sequence: u64) -> String {
367    format!("circles/{circle_id}/acks/{device_id}/{sequence}")
368}
369
370pub fn circle_snapshot_slot_prefix(
371    circle_id: CircleId,
372    device_id: &str,
373    generation: u64,
374) -> String {
375    format!("circles/{circle_id}/snapshots/{device_id}/{generation}")
376}
377
378pub fn circle_snapshot_image_semantic_prefix(
379    circle_id: CircleId,
380    device_id: &str,
381    image_hash: ObjectHash,
382) -> String {
383    format!("circles/{circle_id}/snapshot-images/{device_id}/{image_hash}")
384}
385
386/// Logical metadata prefix for one independently allocated Store snapshot candidate.
387pub fn snapshot_candidate_semantic_prefix(device_id: &str, candidate_id: &str) -> String {
388    format!("{STORE_SNAPSHOT_META_PREFIX}{device_id}/{candidate_id}")
389}
390
391pub fn membership_entry_semantic_prefix(
392    author: &str,
393    author_owner_grant: &MembershipGrantId,
394    stream_id: AuthorStreamId,
395    seq: u64,
396    entry_hash: ObjectHash,
397) -> String {
398    format!(
399        "{STORE_MEMBERSHIP_ENTRY_PREFIX}{author}/{author_owner_grant}/{stream_id}/{seq}/{entry_hash}"
400    )
401}
402
403#[cfg(test)]
404pub(crate) fn membership_head_semantic_prefix(
405    author: &str,
406    author_owner_grant: &MembershipGrantId,
407    stream_id: AuthorStreamId,
408    seq: u64,
409    head_hash: ObjectHash,
410) -> String {
411    format!(
412        "{STORE_MEMBERSHIP_HEAD_PREFIX}{author}/{author_owner_grant}/{stream_id}/{seq}/{head_hash}"
413    )
414}
415
416/// Everything one author stream's head slots share, up to the sequence number.
417///
418/// A head's slot is named by its coordinate alone, so a provider can enumerate
419/// a whole stream under this prefix instead of a reader learning each slot from
420/// the head before it. The founder's first head is the exception: it is written
421/// under [`founder_membership_head_semantic_prefix`] before the founder has a
422/// grant to name, so a listing here starts the founder's stream at sequence 2.
423pub fn membership_head_stream_prefix(
424    author: &str,
425    author_owner_grant: &MembershipGrantId,
426    stream_id: AuthorStreamId,
427) -> String {
428    format!("{STORE_MEMBERSHIP_HEAD_PREFIX}{author}/{author_owner_grant}/{stream_id}/")
429}
430
431pub fn membership_head_slot_prefix(
432    author: &str,
433    author_owner_grant: &MembershipGrantId,
434    stream_id: AuthorStreamId,
435    seq: u64,
436) -> String {
437    format!(
438        "{}{seq}",
439        membership_head_stream_prefix(author, author_owner_grant, stream_id)
440    )
441}
442
443pub fn membership_rollup_semantic_prefix(
444    metadata_slot: &ObjectSlot,
445    rollup_hash: ObjectHash,
446) -> String {
447    let owner = snapshot_artifact_owner(metadata_slot);
448    format!("{STORE_MEMBERSHIP_ROLLUP_PREFIX}{owner}/{rollup_hash}")
449}
450
451pub fn snapshot_image_semantic_prefix(
452    metadata_slot: &ObjectSlot,
453    image_hash: ObjectHash,
454) -> String {
455    let owner = snapshot_artifact_owner(metadata_slot);
456    format!("{STORE_SNAPSHOT_IMAGE_PREFIX}{owner}/{image_hash}")
457}
458
459fn snapshot_artifact_owner(metadata_slot: &ObjectSlot) -> ObjectHash {
460    ObjectHash::digest(&domain_json(
461        b"coven.store-snapshot-artifact-owner.v1\0",
462        metadata_slot,
463    ))
464}
465
466pub(crate) fn domain_json(domain: &[u8], value: &impl Serialize) -> Vec<u8> {
467    let json = serde_json::to_vec(value).expect("canonical Store fields serialize");
468    let mut bytes = Vec::with_capacity(domain.len() + json.len());
469    bytes.extend_from_slice(domain);
470    bytes.extend_from_slice(&json);
471    bytes
472}
473
474pub(crate) fn require_version(version: u32) -> Result<(), StoreProtocolError> {
475    if version == STORE_PROTOCOL_VERSION {
476        Ok(())
477    } else {
478        Err(StoreProtocolError::UnsupportedVersion(version))
479    }
480}
481
482pub(super) fn validate_commit_order(order: &StoreCommitOrder) -> Result<(), StoreProtocolError> {
483    let seq = order.seq();
484    if seq == 0 {
485        return Err(StoreProtocolError::InvalidSequence(0));
486    }
487    {
488        let predecessor = &order.predecessor;
489        let dependencies = &order.dependencies;
490        match (seq, predecessor) {
491            (1, None) => {}
492            (1, Some(_)) => return Err(StoreProtocolError::UnexpectedPredecessor),
493            (_, None) => return Err(StoreProtocolError::MissingPredecessor),
494            (_, Some(reference)) => {
495                if reference.coord.sequence.checked_add(1) != Some(seq) {
496                    return Err(StoreProtocolError::Malformed(
497                        "predecessor is not the preceding author-stream commit".to_string(),
498                    ));
499                }
500            }
501        }
502        for (stream_id, reference) in dependencies {
503            if reference.coord.stream_id != *stream_id || reference.coord.sequence == 0 {
504                return Err(StoreProtocolError::Malformed(format!(
505                    "dependency {stream_id} has a different exact coordinate"
506                )));
507            }
508        }
509    }
510    Ok(())
511}
512
513pub(super) fn validate_commit_predecessor_states(
514    order: &StoreCommitOrder,
515    membership: &StoreMembershipStateRef,
516    devices: &StoreDeviceStateRef,
517) -> Result<(), StoreProtocolError> {
518    membership.validate_shape()?;
519    if membership.recovery() != devices.recovery() {
520        return Err(StoreProtocolError::OwnerRecoveryMismatch);
521    }
522    let expected = order.predecessor_cut()?;
523    if devices.frontier().commits() != expected.commits() {
524        return Err(StoreProtocolError::Malformed(
525            "Store device state names a different Merge predecessor cut".to_string(),
526        ));
527    }
528    Ok(())
529}
530
531pub(crate) fn validate_commit_frontier(
532    frontier: &CommitFrontier,
533) -> Result<(), StoreProtocolError> {
534    {
535        for (stream_id, reference) in &frontier.0 {
536            if reference.coord.stream_id != *stream_id || reference.coord.sequence == 0 {
537                return Err(StoreProtocolError::Malformed(format!(
538                    "frontier entry {stream_id} has a different exact coordinate"
539                )));
540            }
541        }
542        Ok(())
543    }
544}
545
546pub(crate) fn validate_store_history_cut(
547    frontier: &StoreHistoryCut,
548) -> Result<(), StoreProtocolError> {
549    validate_commit_frontier(&CommitFrontier(frontier.0.clone()))
550}
551
552pub(super) fn validate_successor_sequence(
553    sequence: u64,
554    successor: &SuccessorLink,
555) -> Result<(), StoreProtocolError> {
556    match (sequence, successor.predecessor.is_some()) {
557        (0, _) => Err(StoreProtocolError::InvalidAckSequence(0)),
558        (1, false) => Ok(()),
559        (1, true) => Err(StoreProtocolError::UnexpectedAckPredecessor),
560        (_, true) => Ok(()),
561        (_, false) => Err(StoreProtocolError::MissingAckPredecessor),
562    }
563}
564
565pub(super) fn validate_ack_state(
566    store_cut: &StoreHistoryCut,
567    device_state: &StoreDeviceStateRef,
568) -> Result<(), StoreProtocolError> {
569    validate_store_history_cut(store_cut)?;
570    if device_state.frontier() != &store_cut.frontier() {
571        return Err(StoreProtocolError::DeviceStateMismatch);
572    }
573    Ok(())
574}
575
576pub(super) fn validate_membership_coord(coord: &MembershipCoord) -> Result<(), StoreProtocolError> {
577    if coord.seq == 0 || coord.author_pubkey.is_empty() {
578        return Err(StoreProtocolError::InvalidMembershipCoordinate {
579            author: coord.author_pubkey.clone(),
580            grant: coord.author_owner_grant.to_string(),
581            stream_id: coord.stream_id.to_string(),
582            seq: coord.seq,
583            entry_hash: coord.entry_hash.to_string(),
584        });
585    }
586    Ok(())
587}
588
589impl From<coven_foundation::object_hash::InvalidObjectHash> for StoreProtocolError {
590    fn from(error: coven_foundation::object_hash::InvalidObjectHash) -> Self {
591        StoreProtocolError::InvalidObjectHash(error.0)
592    }
593}
594
595impl From<crate::provider::ProviderProbeError> for StoreProtocolError {
596    fn from(error: crate::provider::ProviderProbeError) -> Self {
597        Self::ProviderProbe(Box::new(error))
598    }
599}