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#[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 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}