coven_protocol/membership/
head_acceptance.rs1use 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#[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 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 #[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}