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