Skip to main content

coven_protocol/membership/
head_acceptance.rs

1use super::{
2    AuthorHead, MembershipCoord, MembershipFloor, MembershipHeadActivation, MembershipHeadRef,
3};
4use crate::store_commit::{
5    ObjectHash, Signed, SignedBody, StoreCurrentPublicationRecord, StoreDeviceRegistration,
6    StoreProtocolError, StorePublicationEntry, StorePublicationPayload, StorePublicationRef,
7};
8use coven_keys::keys::UserKeypair;
9use serde::{Deserialize, Serialize};
10
11/// Issued by the head's author after exact Store publication acceptance.
12/// The head binds the commit and transition; this result binds the winning
13/// publication envelope without retaining ordinary publication history.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct MembershipHeadAcceptanceBody {
17    pub store_root_hash: ObjectHash,
18    pub head: MembershipHeadRef,
19    pub issuer: MembershipHeadAcceptanceIssuer,
20    pub accepted_current: StoreCurrentPublicationRecord,
21    /// Exact authority heads before the winning publication, including controls
22    /// accepted while this candidate waited for its publication position.
23    pub accepted_predecessor: MembershipFloor,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MembershipHeadAcceptanceIssuer {
29    Device,
30    OwnerRecovery,
31}
32
33impl SignedBody for MembershipHeadAcceptanceBody {
34    const DOMAIN: &'static [u8] = b"coven.store-membership-head-acceptance.v1\0";
35}
36
37pub type MembershipHeadAcceptance = Signed<MembershipHeadAcceptanceBody>;
38
39impl MembershipHeadAcceptance {
40    /// The publication owner must supply already accepted evidence. Entry
41    /// signatures alone establish preparation, so callers gate signing through
42    /// their accepted-publication capability.
43    #[allow(clippy::too_many_arguments)]
44    pub fn signed(
45        store_root_hash: ObjectHash,
46        head_ref: MembershipHeadRef,
47        head: &AuthorHead,
48        accepted_entry: &StorePublicationEntry,
49        accepted_current: &StoreCurrentPublicationRecord,
50        accepted_predecessor: MembershipFloor,
51        author: &StoreDeviceRegistration,
52        signer: &UserKeypair,
53    ) -> Result<Self, StoreProtocolError> {
54        Self::signed_by(
55            store_root_hash,
56            head_ref,
57            head,
58            accepted_entry,
59            accepted_current,
60            accepted_predecessor,
61            author,
62            MembershipHeadAcceptanceIssuer::Device,
63            signer,
64        )
65    }
66
67    #[allow(clippy::too_many_arguments)]
68    pub fn signed_owner_recovery(
69        store_root_hash: ObjectHash,
70        head_ref: MembershipHeadRef,
71        head: &AuthorHead,
72        accepted_entry: &StorePublicationEntry,
73        accepted_current: &StoreCurrentPublicationRecord,
74        accepted_predecessor: MembershipFloor,
75        author: &StoreDeviceRegistration,
76        principal: &UserKeypair,
77    ) -> Result<Self, StoreProtocolError> {
78        Self::signed_by(
79            store_root_hash,
80            head_ref,
81            head,
82            accepted_entry,
83            accepted_current,
84            accepted_predecessor,
85            author,
86            MembershipHeadAcceptanceIssuer::OwnerRecovery,
87            principal,
88        )
89    }
90
91    #[allow(clippy::too_many_arguments)]
92    fn signed_by(
93        store_root_hash: ObjectHash,
94        head_ref: MembershipHeadRef,
95        head: &AuthorHead,
96        accepted_entry: &StorePublicationEntry,
97        accepted_current: &StoreCurrentPublicationRecord,
98        accepted_predecessor: MembershipFloor,
99        author: &StoreDeviceRegistration,
100        issuer: MembershipHeadAcceptanceIssuer,
101        signer: &UserKeypair,
102    ) -> Result<Self, StoreProtocolError> {
103        let MembershipHeadActivation::StoreCommit { commit, .. } = &head.activation else {
104            return Err(StoreProtocolError::Malformed(
105                "direct membership head cannot have a Store acceptance result".into(),
106            ));
107        };
108        let accepted_ref = accepted_current.accepted().ok_or_else(|| {
109            StoreProtocolError::Malformed("membership acceptance cannot name genesis".into())
110        })?;
111        accepted_current.verify_by(&author.device_signing_pubkey)?;
112        let parsed = StorePublicationEntry::parse_at(
113            &accepted_entry.to_bytes(),
114            store_root_hash,
115            accepted_ref,
116            &author.device_signing_pubkey,
117        )?;
118        let reference = StorePublicationRef::from_entry(&parsed, accepted_ref.object.clone())?;
119        if reference != *accepted_ref
120            || accepted_entry.author_registration != head.body.author_registration
121            || accepted_entry.payload != StorePublicationPayload::Commit(commit.clone())
122        {
123            return Err(StoreProtocolError::Malformed(
124                "membership head acceptance differs from its exact accepted commit".into(),
125            ));
126        }
127        let value = Signed::sign(
128            MembershipHeadAcceptanceBody {
129                store_root_hash,
130                head: head_ref.clone(),
131                issuer,
132                accepted_current: accepted_current.clone(),
133                accepted_predecessor,
134            },
135            signer,
136        );
137        value.verify_for(store_root_hash, &head_ref, head, author)?;
138        Ok(value)
139    }
140
141    pub fn publication(&self) -> Result<&StorePublicationRef, StoreProtocolError> {
142        self.accepted_current.accepted().ok_or_else(|| {
143            StoreProtocolError::Malformed("membership acceptance cannot name genesis".into())
144        })
145    }
146
147    pub fn verify_for(
148        &self,
149        expected_store_root_hash: ObjectHash,
150        head_ref: &MembershipHeadRef,
151        head: &AuthorHead,
152        author: &StoreDeviceRegistration,
153    ) -> Result<(), StoreProtocolError> {
154        self.require_version()?;
155        self.accepted_current
156            .verify_by(&author.device_signing_pubkey)?;
157        let publication = self.publication()?;
158        self.accepted_predecessor.validate().map_err(|error| {
159            StoreProtocolError::Malformed(format!(
160                "invalid accepted membership predecessor: {error}"
161            ))
162        })?;
163        match self.issuer {
164            MembershipHeadAcceptanceIssuer::Device => {
165                self.verify_by(&author.device_signing_pubkey)?
166            }
167            MembershipHeadAcceptanceIssuer::OwnerRecovery => {
168                if !matches!(
169                    author.origin,
170                    crate::store_commit::StoreDeviceRegistrationOrigin::Recovery { .. }
171                ) {
172                    return Err(StoreProtocolError::Malformed(
173                        "Owner recovery acceptance has another registration origin".into(),
174                    ));
175                }
176                self.verify_by(&author.author_pubkey)?;
177            }
178        }
179        head_ref.object.verify(&head.to_bytes())?;
180        let MembershipHeadActivation::StoreCommit {
181            acceptance_slot, ..
182        } = &head.activation
183        else {
184            return Err(StoreProtocolError::Malformed(
185                "direct membership head carries a Store acceptance result".into(),
186            ));
187        };
188        let expected_key = format!(
189            "{}.json",
190            membership_head_acceptance_semantic_prefix(&head_ref.coord)
191        );
192        if acceptance_slot.logical_key() != expected_key {
193            return Err(StoreProtocolError::RelocatedSlot {
194                expected: expected_key,
195                actual: acceptance_slot.logical_key().to_string(),
196            });
197        }
198        if self.store_root_hash != expected_store_root_hash
199            || self.accepted_current.store_root_hash != expected_store_root_hash
200            || publication.store_root_hash != expected_store_root_hash
201            || author.store_root.store_root_hash != expected_store_root_hash
202            || self.head != *head_ref
203            || head_ref.head_hash != head.head_hash()
204            || head_ref.coord != head.entry_coord()
205            || !head.verify(author)
206        {
207            return Err(StoreProtocolError::Malformed(
208                "membership acceptance result differs from its exact rooted head".into(),
209            ));
210        }
211        Ok(())
212    }
213}
214
215pub fn membership_head_acceptance_semantic_prefix(coord: &MembershipCoord) -> String {
216    format!(
217        "store-v1/membership/acceptances/{}/{}/{}/{}",
218        coord.author_pubkey, coord.author_owner_grant, coord.stream_id, coord.seq,
219    )
220}