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