Skip to main content

coven_protocol/
reclaim.rs

1//! Signed reclaim targets, claims, evidence, authorizations, and receipts.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::circle::{CircleBootstrapCoverageRef, CircleControlCoord, CircleId};
8use crate::circle_control::StoreMembershipStateRef;
9use crate::membership::MembershipGrantId;
10use crate::objects::ExactObjectRef;
11use crate::store_commit::{
12    CircleAckRef, CirclePackageRef, CircleSnapshotRef, ObjectHash, Signed, SignedBody,
13    SnapshotImageRef, StoreBatchCommitRef, StoreDeviceRegistration, StoreDeviceRegistrationRef,
14    StorePackageRef, StoreProtocolError,
15};
16use coven_keys::keys::{self, UserKeypair};
17
18const RECLAIM_EVIDENCE_DOMAIN: &[u8] = b"coven.store-reclaim-evidence.v1\0";
19const RECLAIM_AUTHORIZATION_DOMAIN: &[u8] = b"coven.store-reclaim-authorization.v1\0";
20const RECLAIM_RECEIPT_DOMAIN: &[u8] = b"coven.store-reclaim-receipt.v1\0";
21
22/// The exact object a reclaim authorizes the deletion of, together with the
23/// kind-specific locator needed to physically delete it and confirm its absence.
24/// Every kind shares one signed evidence → authorization → receipt chain; the
25/// kind selects only the eligibility proof and the readback prefix.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case", deny_unknown_fields)]
28pub enum ReclaimTarget {
29    StorePackage(StorePackageReclaimTarget),
30    CirclePackage(CirclePackageReclaimTarget),
31    CircleBootstrapImage(CircleBootstrapImageReclaimTarget),
32    CircleSnapshotImage(CircleSnapshotImageReclaimTarget),
33    AudienceBlob(AudienceBlobReclaimTarget),
34}
35
36impl ReclaimTarget {
37    pub fn object(&self) -> &ExactObjectRef {
38        match self {
39            Self::StorePackage(target) => &target.package.object,
40            Self::CirclePackage(target) => &target.package.package.object,
41            Self::CircleBootstrapImage(target) => &target.coverage.bootstrap.image.object,
42            Self::CircleSnapshotImage(target) => &target.image.object,
43            Self::AudienceBlob(target) => target.blob().object(),
44        }
45    }
46
47    pub fn activation(&self) -> ReclaimActivation<'_> {
48        match self {
49            Self::StorePackage(target) => ReclaimActivation::Commit(&target.activation),
50            Self::CirclePackage(target) => ReclaimActivation::Commit(&target.activation),
51            Self::CircleBootstrapImage(target) => {
52                ReclaimActivation::Commit(&target.coverage.activation_commit)
53            }
54            Self::CircleSnapshotImage(target) => {
55                ReclaimActivation::CircleSnapshotMetadata(CircleSnapshotStreamActivation {
56                    circle_id: target.circle_id,
57                    author_registration: &target.snapshot_author,
58                    snapshot: &target.snapshot,
59                })
60            }
61            Self::AudienceBlob(AudienceBlobReclaimTarget::Store { blob }) => {
62                ReclaimActivation::StoreBlobInventory(blob)
63            }
64            Self::AudienceBlob(AudienceBlobReclaimTarget::Circle { source, .. }) => {
65                ReclaimActivation::PackageBlobBinding(source)
66            }
67        }
68    }
69}
70
71/// The signed statement that put a reclaim target into the shared live set — the
72/// authority a verifier re-reads to confirm the Owner is deleting what its claim
73/// says. It follows how the object was published: a Store commit names packages
74/// and the bootstrap images its Circle-control activations carry; a device's
75/// per-Circle snapshot stream names its own images through signed metadata that
76/// rides no commit at all; and a row blob is named by the bindings of the package
77/// that published the row, not by the commit body.
78pub enum ReclaimActivation<'a> {
79    Commit(&'a StoreBatchCommitRef),
80    CircleSnapshotMetadata(CircleSnapshotStreamActivation<'a>),
81    PackageBlobBinding(&'a CirclePackageReclaimTarget),
82    StoreBlobInventory(&'a crate::blob::locator::StoredBlobRef),
83}
84
85impl ReclaimActivation<'_> {
86    pub fn names_authority_object(&self, object: &ExactObjectRef) -> bool {
87        match self {
88            Self::Commit(commit) => &commit.object == object,
89            Self::CircleSnapshotMetadata(source) => &source.snapshot.object == object,
90            Self::PackageBlobBinding(source) => &source.package.package.object == object,
91            // Store inventory authority comes from the verified predecessor's
92            // accepted snapshot, rather than an object supplied by the claim.
93            Self::StoreBlobInventory(_) => false,
94        }
95    }
96}
97
98/// One generation of a device's per-Circle snapshot stream, named by the exact
99/// metadata object whose signature vouches for the image that generation
100/// published. The stream is anchored on the author's Store device registration and
101/// the Circle, which is all a Store member outside the Circle can check; a member
102/// inside re-walks the stream itself.
103pub struct CircleSnapshotStreamActivation<'a> {
104    pub circle_id: CircleId,
105    pub author_registration: &'a StoreDeviceRegistrationRef,
106    pub snapshot: &'a CircleSnapshotRef,
107}
108
109/// The eligibility proof an Owner signs to authorize one reclaim. The claim kind
110/// matches its `ReclaimTarget` kind and carries the exact coverage and
111/// acknowledgement references verified before the target is deleted.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case", deny_unknown_fields)]
114pub enum ReclaimClaim {
115    StorePackage(StorePackageReclaimClaim),
116    CirclePackage(CirclePackageReclaimClaim),
117    CircleBootstrapImage(CircleBootstrapImageReclaimClaim),
118    CircleSnapshotImage(CircleSnapshotImageReclaimClaim),
119    AudienceBlob(AudienceBlobReclaimClaim),
120}
121
122impl ReclaimClaim {
123    pub fn target(&self) -> ReclaimTarget {
124        match self {
125            Self::StorePackage(claim) => ReclaimTarget::StorePackage(claim.target.clone()),
126            Self::CirclePackage(claim) => ReclaimTarget::CirclePackage(claim.target().clone()),
127            Self::CircleBootstrapImage(claim) => {
128                ReclaimTarget::CircleBootstrapImage(claim.target.clone())
129            }
130            Self::CircleSnapshotImage(claim) => {
131                ReclaimTarget::CircleSnapshotImage(claim.target.clone())
132            }
133            Self::AudienceBlob(claim) => ReclaimTarget::AudienceBlob(claim.target.clone()),
134        }
135    }
136
137    fn validate(&self) -> Result<(), StoreProtocolError> {
138        match self {
139            Self::StorePackage(claim) => claim.validate(),
140            Self::CirclePackage(claim) => claim.validate(),
141            Self::CircleBootstrapImage(claim) => claim.validate(),
142            Self::CircleSnapshotImage(claim) => claim.validate(),
143            Self::AudienceBlob(claim) => claim.validate(),
144        }
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct StorePackageReclaimTarget {
151    pub package: StorePackageRef,
152    pub activation: StoreBatchCommitRef,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct CirclePackageReclaimTarget {
158    pub package: CirclePackageRef,
159    pub activation: StoreBatchCommitRef,
160}
161
162/// The exact author, Circle, control, and standalone-snapshot reference of the
163/// stable Circle snapshot whose cut covers a reclaimed Circle package.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct CircleSnapshotLocator {
167    pub author_registration: StoreDeviceRegistrationRef,
168    pub circle_id: CircleId,
169    pub control: CircleControlCoord,
170    pub snapshot: CircleSnapshotRef,
171}
172
173/// The two ways one Circle package stops being live history. Either a stable
174/// Circle snapshot covers it and every active-access device acknowledged that
175/// coverage, or the package lies beyond its epoch's accepted close cutoff — in
176/// which case it never materialized anywhere and needs no coverage evidence.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case", deny_unknown_fields)]
179pub enum CirclePackageReclaimClaim {
180    SnapshotCovered(CirclePackageSnapshotCoverageClaim),
181    BeyondEpochCutoff(CirclePackageBeyondCutoffClaim),
182}
183
184impl CirclePackageReclaimClaim {
185    pub fn target(&self) -> &CirclePackageReclaimTarget {
186        match self {
187            Self::SnapshotCovered(claim) => &claim.target,
188            Self::BeyondEpochCutoff(claim) => &claim.target,
189        }
190    }
191
192    fn validate(&self) -> Result<(), StoreProtocolError> {
193        match self {
194            Self::SnapshotCovered(claim) => claim.validate(),
195            Self::BeyondEpochCutoff(claim) => claim.validate(),
196        }
197    }
198}
199
200/// Evidence that one Circle package lies beyond the accepted cutoff of the epoch
201/// it was addressed to: the named successor control activated with a closed-epoch
202/// origin whose cutoff does not cover the package's activating commit. Such a
203/// package is invalid by construction — no device materializes it — so it needs no
204/// snapshot coverage or acknowledgement evidence. The successor control is an
205/// exact coordinate the verifier re-resolves from retained activations.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct CirclePackageBeyondCutoffClaim {
209    pub target: CirclePackageReclaimTarget,
210    pub successor_control: CircleControlCoord,
211}
212
213impl CirclePackageBeyondCutoffClaim {
214    fn validate(&self) -> Result<(), StoreProtocolError> {
215        self.successor_control.validate()?;
216        if self.successor_control == self.target.package.control {
217            return Err(StoreProtocolError::Malformed(
218                "Circle package beyond-cutoff successor is the package's own control".to_string(),
219            ));
220        }
221        if self.target.package.package.object == self.target.activation.object {
222            return Err(StoreProtocolError::Malformed(
223                "Circle package reclaim target aliases proof authority".to_string(),
224            ));
225        }
226        Ok(())
227    }
228}
229
230/// Evidence that one Circle package is covered by an acknowledgement-stable
231/// Circle snapshot: the snapshot's cut covers the package's activating commit,
232/// and every device holding active Circle access has acknowledged coverage that
233/// dominates the cut. The acknowledgements are exact per-device references,
234/// readable by the Owner as a Circle member.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct CirclePackageSnapshotCoverageClaim {
238    pub target: CirclePackageReclaimTarget,
239    pub covering_snapshot: CircleSnapshotLocator,
240    pub acknowledgements: Vec<CircleAckRef>,
241}
242
243impl CirclePackageSnapshotCoverageClaim {
244    fn validate(&self) -> Result<(), StoreProtocolError> {
245        if self.acknowledgements.is_empty() {
246            return Err(StoreProtocolError::Malformed(
247                "Circle package reclaim evidence has no acknowledgements".to_string(),
248            ));
249        }
250        if self
251            .acknowledgements
252            .windows(2)
253            .any(|pair| pair[0] >= pair[1])
254        {
255            return Err(StoreProtocolError::Malformed(
256                "Circle package reclaim acknowledgements are not strictly sorted and unique"
257                    .to_string(),
258            ));
259        }
260        let circle_id = self.target.package.circle_id;
261        if self.covering_snapshot.circle_id != circle_id
262            || self.target.package.control != self.covering_snapshot.control
263        {
264            return Err(StoreProtocolError::Malformed(
265                "Circle package reclaim target, snapshot, and control name different Circles"
266                    .to_string(),
267            ));
268        }
269        let mut registrations = BTreeSet::new();
270        if self.acknowledgements.iter().any(|acknowledgement| {
271            acknowledgement.circle_id != circle_id
272                || !registrations.insert(&acknowledgement.registration)
273        }) {
274            return Err(StoreProtocolError::Malformed(
275                "Circle package reclaim acknowledgement names another Circle or repeats a device"
276                    .to_string(),
277            ));
278        }
279        let target_object = &self.target.package.package.object;
280        if *target_object == self.target.activation.object
281            || *target_object == self.covering_snapshot.snapshot.object
282            || self
283                .acknowledgements
284                .iter()
285                .any(|acknowledgement| acknowledgement.object == *target_object)
286        {
287            return Err(StoreProtocolError::Malformed(
288                "Circle package reclaim target aliases proof authority".to_string(),
289            ));
290        }
291        Ok(())
292    }
293}
294
295/// The exact Circle bootstrap image a reclaim deletes: the retained bootstrap
296/// coverage a recipient device's live projection was seeded from names the image
297/// object, its activating Store commit, and the cut the seed covers. The coverage
298/// is recovered from the recipient's own signed acknowledgement (`seeded_from`),
299/// never fabricated by the reclaiming Owner.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct CircleBootstrapImageReclaimTarget {
303    pub coverage: CircleBootstrapCoverageRef,
304}
305
306/// The two proofs an Owner can present that a recipient no longer needs its seed
307/// image. Both carry the recipient device's own activated Circle acknowledgement,
308/// whose `seeded_from` names the target coverage — binding the proof to the exact
309/// image being deleted. The authorization verifier re-loads and re-checks the
310/// acknowledgement; nothing here is trusted from the claim alone.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case", deny_unknown_fields)]
313pub enum CircleBootstrapReclaimProof {
314    /// The recipient advanced past its seed: its acknowledgement's accepted Store
315    /// frontier strictly dominates the bootstrap's cut, and its owner still holds
316    /// active Circle access.
317    RecipientCoverage { acknowledgement: CircleAckRef },
318    /// The recipient lost Circle authority: its owner is absent from the roster of
319    /// an activated successor control that strictly covers the seed's control.
320    LostAuthority {
321        acknowledgement: CircleAckRef,
322        successor_control: CircleControlCoord,
323    },
324}
325
326impl CircleBootstrapReclaimProof {
327    pub fn acknowledgement(&self) -> &CircleAckRef {
328        match self {
329            Self::RecipientCoverage { acknowledgement }
330            | Self::LostAuthority {
331                acknowledgement, ..
332            } => acknowledgement,
333        }
334    }
335}
336
337/// Evidence that one Circle bootstrap image is no longer a live seed for its
338/// recipient: the target image and the recipient-coverage or lost-authority proof.
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct CircleBootstrapImageReclaimClaim {
342    pub target: CircleBootstrapImageReclaimTarget,
343    pub proof: CircleBootstrapReclaimProof,
344}
345
346impl CircleBootstrapImageReclaimClaim {
347    fn validate(&self) -> Result<(), StoreProtocolError> {
348        let circle_id = self.target.coverage.circle_id;
349        let acknowledgement = self.proof.acknowledgement();
350        if acknowledgement.circle_id != circle_id {
351            return Err(StoreProtocolError::Malformed(
352                "Circle bootstrap reclaim acknowledgement names another Circle".to_string(),
353            ));
354        }
355        let image = &self.target.coverage.bootstrap.image.object;
356        if *image == self.target.coverage.activation_commit.object
357            || *image == acknowledgement.object
358        {
359            return Err(StoreProtocolError::Malformed(
360                "Circle bootstrap reclaim target aliases proof authority".to_string(),
361            ));
362        }
363        if let CircleBootstrapReclaimProof::LostAuthority {
364            successor_control, ..
365        } = &self.proof
366        {
367            successor_control.validate()?;
368            if *successor_control == self.target.coverage.control {
369                return Err(StoreProtocolError::Malformed(
370                    "Circle bootstrap lost-authority successor is the seed control".to_string(),
371                ));
372            }
373        }
374        Ok(())
375    }
376}
377
378/// The exact image of one generation of a device's standalone Circle snapshot
379/// stream.
380///
381/// Only the image ciphertext is ever a reclaim target. A reader reconstructs the
382/// stream by walking it from generation zero along each metadata object's
383/// create-once successor slot and stopping at the first slot that is absent, so
384/// deleting any generation's metadata hides every later generation from every
385/// reader — the metadata chain is permanent regardless of how superseded the
386/// generation is.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct CircleSnapshotImageReclaimTarget {
390    pub circle_id: CircleId,
391    pub snapshot_author: StoreDeviceRegistrationRef,
392    pub control: CircleControlCoord,
393    pub snapshot: CircleSnapshotRef,
394    pub image: SnapshotImageRef,
395}
396
397impl CircleSnapshotImageReclaimTarget {
398    /// The ownership record's owner for this image: the device-authorized
399    /// activation of the author's per-Circle snapshot stream, at this generation.
400    /// Derived from the target's own identity rather than carried in it, so an
401    /// ownership record can only close against the generation that published it.
402    pub fn snapshot_owner(
403        &self,
404        store_root_hash: ObjectHash,
405    ) -> Result<crate::remote_object::SnapshotObjectOwner, StoreProtocolError> {
406        Ok(crate::remote_object::SnapshotObjectOwner::Circle {
407            activation: crate::store_commit::circle_snapshot_stream_activation(
408                store_root_hash,
409                &self.snapshot_author,
410                self.circle_id,
411            )?,
412            generation: self.snapshot.generation,
413        })
414    }
415}
416
417/// Evidence that a later generation of the same device's Circle snapshot stream
418/// supersedes the reclaimed one. The claim names only the exact superseding
419/// generation — that generation's own signed metadata, its stability against every
420/// active-access device's acknowledgement, and its coverage of the reclaimed cut
421/// are all re-derived from live state at verification.
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct CircleSnapshotImageReclaimClaim {
425    pub target: CircleSnapshotImageReclaimTarget,
426    pub superseding: CircleSnapshotRef,
427}
428
429impl CircleSnapshotImageReclaimClaim {
430    fn validate(&self) -> Result<(), StoreProtocolError> {
431        if self.superseding.generation <= self.target.snapshot.generation {
432            return Err(StoreProtocolError::Malformed(
433                "Circle snapshot reclaim names a superseding generation that is not later"
434                    .to_string(),
435            ));
436        }
437        let image = &self.target.image.object;
438        if *image == self.target.snapshot.object || *image == self.superseding.object {
439            return Err(StoreProtocolError::Malformed(
440                "Circle snapshot reclaim target aliases proof authority".to_string(),
441            ));
442        }
443        Ok(())
444    }
445}
446
447/// The exact package whose row-blob bindings published one blob, in whichever
448/// audience the row was written to. Reading the package back needs its audience:
449/// a Store package is sealed to the Store, a Circle package to the Circle epoch.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case", deny_unknown_fields)]
452pub enum AudienceBlobBindingPackage {
453    Store(StorePackageRef),
454    Circle(CirclePackageRef),
455}
456
457impl AudienceBlobBindingPackage {
458    pub fn object(&self) -> &ExactObjectRef {
459        match self {
460            Self::Store(package) => &package.object,
461            Self::Circle(package) => &package.package.object,
462        }
463    }
464
465    pub fn remote_audience(&self) -> crate::blob::locator::RemoteAudience {
466        match self {
467            Self::Store(_) => crate::blob::locator::RemoteAudience::Store,
468            Self::Circle(package) => {
469                crate::blob::locator::RemoteAudience::Circle(package.circle_id)
470            }
471        }
472    }
473}
474
475/// The exact ciphertext of one row blob that no live row still binds in its
476/// audience. Moving a row to another audience republishes its blob under a new
477/// locator and drops the old binding, leaving the source ciphertext addressed to
478/// an audience nothing reads from any more.
479///
480/// The blob reference is self-binding: its object's logical key is derived from
481/// the locator, which names the audience and the uploading device, so a target
482/// cannot describe one object while naming another's addressing.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case", deny_unknown_fields)]
485pub enum AudienceBlobReclaimTarget {
486    Store {
487        blob: crate::blob::locator::StoredBlobRef,
488    },
489    Circle {
490        blob: crate::blob::locator::StoredBlobRef,
491        source: CirclePackageReclaimTarget,
492    },
493}
494
495impl AudienceBlobReclaimTarget {
496    pub fn blob(&self) -> &crate::blob::locator::StoredBlobRef {
497        match self {
498            Self::Store { blob } | Self::Circle { blob, .. } => blob,
499        }
500    }
501}
502
503/// Evidence that a row blob is no longer bound by any live row. The claim carries
504/// nothing but the target: the verifier re-reads the publishing package to confirm
505/// it bound this blob, then re-derives from its own materialized rows that none
506/// still binds it.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(deny_unknown_fields)]
509pub struct AudienceBlobReclaimClaim {
510    pub target: AudienceBlobReclaimTarget,
511}
512
513impl AudienceBlobReclaimClaim {
514    fn validate(&self) -> Result<(), StoreProtocolError> {
515        let audience = match &self.target {
516            AudienceBlobReclaimTarget::Store { .. } => crate::blob::locator::RemoteAudience::Store,
517            AudienceBlobReclaimTarget::Circle { blob, source } => {
518                if blob.object() == &source.package.package.object
519                    || blob.object() == &source.activation.object
520                {
521                    return Err(StoreProtocolError::Malformed(
522                        "audience blob reclaim target aliases proof authority".to_string(),
523                    ));
524                }
525                crate::blob::locator::RemoteAudience::Circle(source.package.circle_id)
526            }
527        };
528        if self.target.blob().locator().audience() != audience {
529            return Err(StoreProtocolError::Malformed(
530                "audience blob reclaim target names a package for another audience".to_string(),
531            ));
532        }
533        Ok(())
534    }
535}
536
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct StorePackageReclaimClaim {
540    pub target: StorePackageReclaimTarget,
541}
542
543impl StorePackageReclaimClaim {
544    fn validate(&self) -> Result<(), StoreProtocolError> {
545        if self.target.package.object == self.target.activation.object {
546            return Err(StoreProtocolError::Malformed(
547                "Store package reclaim target aliases its activation".to_string(),
548            ));
549        }
550        Ok(())
551    }
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(deny_unknown_fields)]
556pub struct ReclaimEvidenceRef {
557    pub evidence_hash: ObjectHash,
558    pub target: Box<ReclaimTarget>,
559    pub object: ExactObjectRef,
560}
561
562impl ReclaimEvidenceRef {
563    pub fn from_evidence(evidence: &ReclaimEvidence, object: ExactObjectRef) -> Self {
564        Self {
565            evidence_hash: evidence.evidence_hash(),
566            target: Box::new(evidence.claim.target()),
567            object,
568        }
569    }
570
571    pub fn verify(&self, evidence: &ReclaimEvidence) -> Result<(), StoreProtocolError> {
572        let actual = evidence.evidence_hash();
573        if actual != self.evidence_hash {
574            return Err(StoreProtocolError::ObjectHashMismatch {
575                expected: self.evidence_hash,
576                actual,
577            });
578        }
579        if evidence.claim.target() != *self.target {
580            return Err(StoreProtocolError::Malformed(
581                "reclaim target differs from its exact evidence reference".to_string(),
582            ));
583        }
584        evidence.verify()
585    }
586}
587
588/// The wire body of a reclaim claim's evidence. Every field here is signed.
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(deny_unknown_fields)]
591pub struct ReclaimEvidenceBody {
592    pub store_root_hash: ObjectHash,
593    pub claim: ReclaimClaim,
594    pub author_pubkey: String,
595}
596
597impl SignedBody for ReclaimEvidenceBody {
598    const DOMAIN: &'static [u8] = RECLAIM_EVIDENCE_DOMAIN;
599}
600
601pub type ReclaimEvidence = Signed<ReclaimEvidenceBody>;
602
603impl ReclaimEvidence {
604    pub fn signed(
605        store_root_hash: ObjectHash,
606        claim: ReclaimClaim,
607        signer: &UserKeypair,
608    ) -> Result<Self, StoreProtocolError> {
609        claim.validate()?;
610        Ok(Signed::sign(
611            ReclaimEvidenceBody {
612                store_root_hash,
613                claim,
614                author_pubkey: keys::public_key_hex(signer),
615            },
616            signer,
617        ))
618    }
619
620    pub fn evidence_hash(&self) -> ObjectHash {
621        self.hash()
622    }
623
624    pub fn verify(&self) -> Result<(), StoreProtocolError> {
625        self.claim.validate()?;
626        let author_pubkey = self.author_pubkey.clone();
627        self.verify_by(&author_pubkey)
628    }
629}
630
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(deny_unknown_fields)]
633pub struct StoreReclaimAuthority {
634    pub membership: StoreMembershipStateRef,
635    pub owner_grant: MembershipGrantId,
636}
637
638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
639#[serde(deny_unknown_fields)]
640pub struct ReclaimAuthorizationRef {
641    pub authorization_hash: ObjectHash,
642    pub evidence: ReclaimEvidenceRef,
643    pub object: ExactObjectRef,
644}
645
646impl ReclaimAuthorizationRef {
647    pub fn from_authorization(
648        authorization: &ReclaimAuthorization,
649        object: ExactObjectRef,
650    ) -> Self {
651        Self {
652            authorization_hash: authorization.authorization_hash(),
653            evidence: authorization.evidence.clone(),
654            object,
655        }
656    }
657
658    pub fn verify_identity(
659        &self,
660        authorization: &ReclaimAuthorization,
661    ) -> Result<(), StoreProtocolError> {
662        let actual = authorization.authorization_hash();
663        if actual != self.authorization_hash {
664            return Err(StoreProtocolError::ObjectHashMismatch {
665                expected: self.authorization_hash,
666                actual,
667            });
668        }
669        if authorization.evidence != self.evidence || authorization.target != *self.evidence.target
670        {
671            return Err(StoreProtocolError::Malformed(
672                "reclaim authorization target or evidence differs from its exact reference"
673                    .to_string(),
674            ));
675        }
676        Ok(())
677    }
678
679    pub fn target(&self) -> &ReclaimTarget {
680        &self.evidence.target
681    }
682
683    pub fn target_activation(&self) -> ReclaimActivation<'_> {
684        self.evidence.target.activation()
685    }
686
687    pub fn verify(
688        &self,
689        authorization: &ReclaimAuthorization,
690        owner_pubkey: &str,
691    ) -> Result<(), StoreProtocolError> {
692        self.verify_identity(authorization)?;
693        authorization.verify(owner_pubkey)
694    }
695}
696
697/// The wire body of an Owner's authorization to reclaim. Every field here is
698/// signed.
699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(deny_unknown_fields)]
701pub struct ReclaimAuthorizationBody {
702    pub store_root_hash: ObjectHash,
703    pub target: ReclaimTarget,
704    pub evidence: ReclaimEvidenceRef,
705    pub authority: StoreReclaimAuthority,
706}
707
708impl SignedBody for ReclaimAuthorizationBody {
709    const DOMAIN: &'static [u8] = RECLAIM_AUTHORIZATION_DOMAIN;
710}
711
712pub type ReclaimAuthorization = Signed<ReclaimAuthorizationBody>;
713
714impl ReclaimAuthorization {
715    pub fn signed(
716        store_root_hash: ObjectHash,
717        target: ReclaimTarget,
718        evidence: ReclaimEvidenceRef,
719        authority: StoreReclaimAuthority,
720        signer: &UserKeypair,
721    ) -> Self {
722        Signed::sign(
723            ReclaimAuthorizationBody {
724                store_root_hash,
725                target,
726                evidence,
727                authority,
728            },
729            signer,
730        )
731    }
732
733    pub fn authorization_hash(&self) -> ObjectHash {
734        self.hash()
735    }
736
737    pub fn verify(&self, owner_pubkey: &str) -> Result<(), StoreProtocolError> {
738        self.verify_by(owner_pubkey)
739    }
740}
741
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
743#[serde(deny_unknown_fields)]
744pub struct ReclaimReceiptRef {
745    pub receipt_hash: ObjectHash,
746    pub authorization: ReclaimAuthorizationRef,
747    pub object: ExactObjectRef,
748}
749
750impl ReclaimReceiptRef {
751    pub fn from_receipt(receipt: &ReclaimReceipt, object: ExactObjectRef) -> Self {
752        Self {
753            receipt_hash: receipt.receipt_hash(),
754            authorization: receipt.authorization.clone(),
755            object,
756        }
757    }
758
759    pub fn verify_identity(&self, receipt: &ReclaimReceipt) -> Result<(), StoreProtocolError> {
760        let actual = receipt.receipt_hash();
761        if actual != self.receipt_hash {
762            return Err(StoreProtocolError::ObjectHashMismatch {
763                expected: self.receipt_hash,
764                actual,
765            });
766        }
767        if receipt.authorization != self.authorization {
768            return Err(StoreProtocolError::Malformed(
769                "reclaim receipt authorization differs from its exact reference".to_string(),
770            ));
771        }
772        Ok(())
773    }
774
775    pub fn verify(
776        &self,
777        receipt: &ReclaimReceipt,
778        executor: &StoreDeviceRegistration,
779    ) -> Result<(), StoreProtocolError> {
780        self.verify_identity(receipt)?;
781        receipt.verify(executor)
782    }
783}
784
785/// The wire body of a reclaim receipt: what was reclaimed, and under whose
786/// authority. Every field here is signed.
787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
788#[serde(deny_unknown_fields)]
789pub struct ReclaimReceiptBody {
790    pub store_root_hash: ObjectHash,
791    pub authorization: ReclaimAuthorizationRef,
792    pub provider_admin_state: StoreMembershipStateRef,
793    pub provider_admin_grant: crate::provider::ProviderAdminGrantId,
794    pub executor: StoreDeviceRegistrationRef,
795}
796
797impl SignedBody for ReclaimReceiptBody {
798    const DOMAIN: &'static [u8] = RECLAIM_RECEIPT_DOMAIN;
799}
800
801pub type ReclaimReceipt = Signed<ReclaimReceiptBody>;
802
803impl ReclaimReceipt {
804    #[allow(clippy::too_many_arguments)]
805    pub fn signed(
806        store_root_hash: ObjectHash,
807        authorization: ReclaimAuthorizationRef,
808        provider_admin_state: StoreMembershipStateRef,
809        provider_admin_grant: crate::provider::ProviderAdminGrantId,
810        executor: StoreDeviceRegistrationRef,
811        executor_registration: &StoreDeviceRegistration,
812        signer: &UserKeypair,
813    ) -> Result<Self, StoreProtocolError> {
814        executor.verify_registration(executor_registration)?;
815        crate::objects::verify_store_root(
816            store_root_hash,
817            executor_registration.store_root.store_root_hash,
818        )?;
819        if keys::public_key_hex(signer) != executor_registration.device_signing_pubkey {
820            return Err(StoreProtocolError::InvalidSignature);
821        }
822        Ok(Signed::sign(
823            ReclaimReceiptBody {
824                store_root_hash,
825                authorization,
826                provider_admin_state,
827                provider_admin_grant,
828                executor,
829            },
830            signer,
831        ))
832    }
833
834    pub fn receipt_hash(&self) -> ObjectHash {
835        self.hash()
836    }
837
838    pub fn verify(&self, executor: &StoreDeviceRegistration) -> Result<(), StoreProtocolError> {
839        self.executor.verify_registration(executor)?;
840        crate::objects::verify_store_root(
841            self.store_root_hash,
842            executor.store_root.store_root_hash,
843        )?;
844        self.verify_by(&executor.device_signing_pubkey)
845    }
846}
847
848pub fn reclaim_evidence_semantic_prefix(evidence_hash: ObjectHash) -> String {
849    format!("store-v1/reclaim/evidence/{evidence_hash}")
850}
851
852pub fn reclaim_authorization_semantic_prefix(authorization_hash: ObjectHash) -> String {
853    format!("store-v1/reclaim/authorizations/{authorization_hash}")
854}
855
856pub fn reclaim_receipt_semantic_prefix(receipt_hash: ObjectHash) -> String {
857    format!("store-v1/reclaim/receipts/{receipt_hash}")
858}
859
860#[cfg(test)]
861mod tests;