Skip to main content

coven_protocol/provider/
cross_principal.rs

1use super::probe::*;
2use super::*;
3
4#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case", deny_unknown_fields)]
6pub enum CrossPrincipalProviderEvidence {
7    GoogleSharedDrive,
8    DropboxSharedNamespace,
9    OneDriveSharedFolder,
10    CloudKit(CloudKitAcceptedShare),
11}
12
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct CloudKitAcceptedShare {
16    pub share: ExactObjectRef,
17    pub share_record_name: String,
18    pub owner_name: String,
19    pub zone_name: String,
20    pub participant_record_name: String,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct CrossPrincipalProbeTranscript {
26    pub challenge: CrossPrincipalProbeChallenge,
27    pub response: CrossPrincipalProbeResponse,
28    pub administrator_read_peer_hash: ObjectHash,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct CrossPrincipalProbeChallenge {
34    pub probe_id: ProviderProbeId,
35    pub administrator_object: ProbeExactObjectReceipt,
36    pub challenge_hash: ObjectHash,
37    pub administrator_signature: String,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct CrossPrincipalProbeResponse {
43    pub challenge_hash: ObjectHash,
44    pub provider_evidence: CrossPrincipalProviderEvidence,
45    pub peer_object: ProbeExactObjectReceipt,
46    pub peer_read_administrator_hash: ObjectHash,
47    pub response_hash: ObjectHash,
48    pub peer_signature: String,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct CrossPrincipalProbeReceipt {
54    pub transcript: CrossPrincipalProbeTranscript,
55    pub transcript_hash: ObjectHash,
56    pub administrator_completion_signature: String,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct CrossPrincipalChallengeContext {
62    pub root: StoreRootRef,
63    pub attempt_id: DeviceJoinAttemptId,
64    pub access_request_hash: ObjectHash,
65    pub provider_admin_grant: ProviderAdminGrantId,
66    pub owner_registration: StoreDeviceRegistrationRef,
67    pub member_pubkey: String,
68    pub administrator_binding: ProviderDeviceBinding,
69    pub peer_binding: ProviderDeviceBinding,
70}
71
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct CrossPrincipalResponseContext {
75    pub challenge: CrossPrincipalChallengeContext,
76    pub expected_registration_hash: ObjectHash,
77    pub response_slot: ObjectSlot,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct DeviceJoinChallengePublicationAuthorization {
83    pub attempt_id: DeviceJoinAttemptId,
84    pub attempt_activation: StoreBatchCommitRef,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct DeviceJoinChallengePublicationRecord {
90    pub challenge: CrossPrincipalProbeChallenge,
91    pub progress: DeviceJoinChallengePublicationProgress,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case", deny_unknown_fields)]
96pub enum DeviceJoinChallengePublicationProgress {
97    Prepared,
98    Published {
99        authorization: DeviceJoinChallengePublicationAuthorization,
100    },
101}
102
103#[async_trait]
104pub trait DeviceJoinChallengePublicationJournal: Send + Sync {
105    async fn prepare(
106        &self,
107        challenge: &CrossPrincipalProbeChallenge,
108    ) -> Result<DeviceJoinChallengePublicationRecord, StorageError>;
109
110    /// Atomically claims publication for these exact signed facts. An exact
111    /// replay of an existing `Published` claim succeeds; a claim naming a
112    /// different authorization is rejected.
113    async fn claim_published(
114        &self,
115        authorization: &DeviceJoinChallengePublicationAuthorization,
116        challenge: &CrossPrincipalProbeChallenge,
117    ) -> Result<(), StorageError>;
118}
119
120impl CrossPrincipalProbeReceipt {
121    pub fn signed(
122        transcript: CrossPrincipalProbeTranscript,
123        context: &CrossPrincipalResponseContext,
124        store: &StoreProviderBinding,
125        administrator_signer: &dyn coven_keys::keys::DeviceSigningAuthority,
126    ) -> Result<Self, ProviderProbeError> {
127        validate_cross_transcript_payloads(&transcript, context)?;
128        let transcript_hash = cross_transcript_hash(store, context, &transcript);
129        Ok(Self {
130            transcript,
131            transcript_hash,
132            administrator_completion_signature: hex::encode(
133                administrator_signer.sign(transcript_hash.as_bytes()),
134            ),
135        })
136    }
137
138    pub fn verify(
139        &self,
140        context: &CrossPrincipalResponseContext,
141        store: &StoreProviderBinding,
142        administrator_signing_pubkey: &str,
143        peer_signing_pubkey: &str,
144    ) -> Result<(), ProviderProbeError> {
145        validate_cross_provider_evidence(
146            store,
147            &context.challenge.administrator_binding,
148            &context.challenge.peer_binding,
149            &self.transcript.response.provider_evidence,
150        )?;
151        self.transcript.challenge.verify(
152            &context.challenge,
153            store,
154            administrator_signing_pubkey,
155        )?;
156        self.transcript.response.verify(
157            &self.transcript.challenge,
158            context,
159            store,
160            administrator_signing_pubkey,
161            peer_signing_pubkey,
162        )?;
163        validate_cross_transcript_payloads(&self.transcript, context)?;
164        let expected_hash = cross_transcript_hash(store, context, &self.transcript);
165        if self.transcript_hash != expected_hash {
166            return invalid("cross-principal transcript hash does not match its join context");
167        }
168        if !coven_keys::keys::verify_signature_hex(
169            administrator_signing_pubkey,
170            &self.administrator_completion_signature,
171            self.transcript_hash.as_bytes(),
172        ) {
173            return invalid("cross-principal completion signature is invalid");
174        }
175        Ok(())
176    }
177}
178
179impl CrossPrincipalProbeChallenge {
180    pub fn verify(
181        &self,
182        context: &CrossPrincipalChallengeContext,
183        store: &StoreProviderBinding,
184        administrator_signing_pubkey: &str,
185    ) -> Result<(), ProviderProbeError> {
186        validate_cross_challenge_payload(self)?;
187        validate_cross_provider_evidence_context(store, context)?;
188        let expected_hash = cross_challenge_hash(store, context, self);
189        if self.challenge_hash != expected_hash {
190            return invalid("cross-principal challenge hash does not match its join context");
191        }
192        if !coven_keys::keys::verify_signature_hex(
193            administrator_signing_pubkey,
194            &self.administrator_signature,
195            self.challenge_hash.as_bytes(),
196        ) {
197            return invalid("cross-principal challenge signature is invalid");
198        }
199        Ok(())
200    }
201}
202
203impl CrossPrincipalProbeResponse {
204    pub fn verify(
205        &self,
206        challenge: &CrossPrincipalProbeChallenge,
207        context: &CrossPrincipalResponseContext,
208        store: &StoreProviderBinding,
209        administrator_signing_pubkey: &str,
210        peer_signing_pubkey: &str,
211    ) -> Result<(), ProviderProbeError> {
212        challenge.verify(&context.challenge, store, administrator_signing_pubkey)?;
213        if context.challenge.member_pubkey != peer_signing_pubkey {
214            return invalid("cross-principal response signer is not the joining member");
215        }
216        validate_cross_provider_evidence(
217            store,
218            &context.challenge.administrator_binding,
219            &context.challenge.peer_binding,
220            &self.provider_evidence,
221        )?;
222        validate_cross_response_payload(self, challenge, context)?;
223        let expected_hash = cross_response_hash(store, context, challenge, self);
224        if self.response_hash != expected_hash {
225            return invalid("cross-principal response hash does not match its join context");
226        }
227        if !coven_keys::keys::verify_signature_hex(
228            peer_signing_pubkey,
229            &self.peer_signature,
230            self.response_hash.as_bytes(),
231        ) {
232            return invalid("cross-principal response signature is invalid");
233        }
234        Ok(())
235    }
236}
237
238pub(crate) fn cross_transcript_hash(
239    store: &StoreProviderBinding,
240    context: &CrossPrincipalResponseContext,
241    transcript: &CrossPrincipalProbeTranscript,
242) -> ObjectHash {
243    ObjectHash::digest(&domain_json(
244        CROSS_TRANSCRIPT_DOMAIN,
245        &(store, context, transcript),
246    ))
247}
248
249pub(crate) fn validate_cross_transcript_payloads(
250    transcript: &CrossPrincipalProbeTranscript,
251    context: &CrossPrincipalResponseContext,
252) -> Result<(), ProviderProbeError> {
253    validate_cross_challenge_payload(&transcript.challenge)?;
254    validate_cross_response_payload(&transcript.response, &transcript.challenge, context)?;
255    let peer = probe_payload(&transcript.challenge.probe_id, ProbePayloadLabel::CrossPeer);
256    if transcript.administrator_read_peer_hash != ObjectHash::digest(&peer) {
257        return invalid("cross-principal object, read, or deletion evidence is invalid");
258    }
259    Ok(())
260}
261
262pub fn cross_challenge_hash(
263    store: &StoreProviderBinding,
264    context: &CrossPrincipalChallengeContext,
265    challenge: &CrossPrincipalProbeChallenge,
266) -> ObjectHash {
267    ObjectHash::digest(&domain_json(
268        CROSS_CHALLENGE_DOMAIN,
269        &(
270            store,
271            context,
272            challenge.probe_id,
273            &challenge.administrator_object,
274        ),
275    ))
276}
277
278pub fn cross_response_hash(
279    store: &StoreProviderBinding,
280    context: &CrossPrincipalResponseContext,
281    challenge: &CrossPrincipalProbeChallenge,
282    response: &CrossPrincipalProbeResponse,
283) -> ObjectHash {
284    ObjectHash::digest(&domain_json(
285        CROSS_RESPONSE_DOMAIN,
286        &(
287            store,
288            context,
289            challenge.challenge_hash,
290            &response.provider_evidence,
291            &response.peer_object,
292            response.peer_read_administrator_hash,
293        ),
294    ))
295}
296
297pub(crate) fn validate_cross_challenge_payload(
298    challenge: &CrossPrincipalProbeChallenge,
299) -> Result<(), ProviderProbeError> {
300    let expected_key = cross_administrator_logical_key(challenge.probe_id);
301    let payload = probe_payload(&challenge.probe_id, ProbePayloadLabel::CrossAdministrator);
302    validate_probe_exact_object(
303        &challenge.administrator_object,
304        &expected_key,
305        &payload,
306        "cross-principal challenge",
307    )
308}
309
310pub(crate) fn validate_cross_response_payload(
311    response: &CrossPrincipalProbeResponse,
312    challenge: &CrossPrincipalProbeChallenge,
313    context: &CrossPrincipalResponseContext,
314) -> Result<(), ProviderProbeError> {
315    let administrator = probe_payload(&challenge.probe_id, ProbePayloadLabel::CrossAdministrator);
316    let peer = probe_payload(&challenge.probe_id, ProbePayloadLabel::CrossPeer);
317    if response.challenge_hash != challenge.challenge_hash
318        || response.peer_object.slot != context.response_slot
319        || response.peer_read_administrator_hash != ObjectHash::digest(&administrator)
320    {
321        return invalid(
322            "cross-principal response disagrees with its challenge or response context",
323        );
324    }
325    validate_probe_exact_object(
326        &response.peer_object,
327        &cross_peer_logical_key(challenge.probe_id),
328        &peer,
329        "cross-principal response",
330    )
331}
332
333pub(super) fn cross_administrator_logical_key(probe_id: ProviderProbeId) -> String {
334    format!(
335        "__coven_probe__/cross/{}/administrator",
336        hex::encode(probe_id.as_bytes())
337    )
338}
339
340pub fn cross_peer_logical_key(probe_id: ProviderProbeId) -> String {
341    format!(
342        "__coven_probe__/cross/{}/peer",
343        hex::encode(probe_id.as_bytes())
344    )
345}
346
347pub fn validate_cross_provider_evidence_context(
348    store: &StoreProviderBinding,
349    context: &CrossPrincipalChallengeContext,
350) -> Result<(), ProviderProbeError> {
351    context
352        .administrator_binding
353        .validate_for(store)
354        .map_err(ProviderProbeError::Storage)?;
355    context
356        .peer_binding
357        .validate_for(store)
358        .map_err(ProviderProbeError::Storage)?;
359    if context.administrator_binding == context.peer_binding {
360        return invalid("cross-principal context uses the same provider principal twice");
361    }
362    Ok(())
363}
364
365pub fn validate_cross_provider_evidence(
366    store: &StoreProviderBinding,
367    administrator: &ProviderDeviceBinding,
368    peer: &ProviderDeviceBinding,
369    evidence: &CrossPrincipalProviderEvidence,
370) -> Result<(), ProviderProbeError> {
371    administrator
372        .validate_for(store)
373        .map_err(ProviderProbeError::Storage)?;
374    peer.validate_for(store)
375        .map_err(ProviderProbeError::Storage)?;
376    if administrator == peer {
377        return invalid("cross-principal receipt uses the same provider principal twice");
378    }
379    let compatible = matches!(
380        (store, evidence),
381        (
382            StoreProviderBinding::GoogleDrive {
383                corpus: crate::objects::GoogleDriveCorpus::SharedDrive { .. }
384            },
385            CrossPrincipalProviderEvidence::GoogleSharedDrive
386        ) | (
387            StoreProviderBinding::Dropbox { .. },
388            CrossPrincipalProviderEvidence::DropboxSharedNamespace
389        ) | (
390            StoreProviderBinding::OneDrive { .. },
391            CrossPrincipalProviderEvidence::OneDriveSharedFolder
392        ) | (
393            StoreProviderBinding::CloudKit { .. },
394            CrossPrincipalProviderEvidence::CloudKit(_)
395        )
396    );
397    if !compatible {
398        return invalid("provider binding does not permit the cross-principal evidence");
399    }
400    if let (
401        StoreProviderBinding::CloudKit {
402            owner_name,
403            zone_name,
404            ..
405        },
406        CrossPrincipalProviderEvidence::CloudKit(accepted),
407    ) = (store, evidence)
408    {
409        let crate::objects::ProviderPrincipalId::CloudKitSharedZoneParticipant { record_name } =
410            &peer.principal
411        else {
412            return invalid("CloudKit peer is not a shared-zone participant");
413        };
414        if accepted.owner_name != *owner_name
415            || accepted.zone_name != *zone_name
416            || accepted.participant_record_name != *record_name
417            || accepted.share_record_name.is_empty()
418        {
419            return invalid("CloudKit accepted-share evidence differs from the Store binding");
420        }
421    }
422    Ok(())
423}