Skip to main content

coven_core/sync/store/
device_join_transport.rs

1//! Storage-mediated delivery for the device-join exchange.
2//!
3//! The join protocol in [`crate::sync::store::device_join`] produces nine signed
4//! artifacts plus the unwind artifacts, and hands each to the host as a
5//! [`DeviceJoinAction`] to deliver however it likes. This module is the delivery
6//! coven ships by default: each artifact travels as one create-once object in
7//! the store's cloud home, under a per-attempt namespace, sealed with a key
8//! minted for that attempt alone.
9//!
10//! The layer carries bytes and nothing else. It never inspects an artifact
11//! beyond naming which slot it belongs in, and unsealing is not part of the
12//! trust story — the artifact's own signature and hash chaining, checked by the
13//! protocol when it accepts the artifact, are.
14//!
15//! The offer does not travel here. It is the out-of-band kickoff: the host
16//! encodes a [`DeviceJoinOfferBundle`] (the offer plus the slots and seal key
17//! this module needs) as a QR, a link, or a typed code, and the joiner's copy of
18//! that bundle is what bootstraps everything below.
19
20use std::collections::BTreeMap;
21use std::time::Duration;
22
23use serde::{Deserialize, Serialize};
24
25use crate::encryption::{EncryptionService, MasterKeyring, SealError};
26use crate::keys::UserKeypair;
27use crate::storage::cloud::ObjectSlot;
28use crate::sync::storage::{
29    ProtocolObjectContext, ProtocolObjectDomain, StorageError, SyncStorage,
30};
31use crate::sync::store::{
32    DeviceJoinAbandonment, DeviceJoinAction, DeviceJoinActivation, DeviceJoinCancellation,
33    DeviceJoinCleanupActivation, DeviceJoinCleanupReceipt, DeviceJoinError, DeviceJoinOffer,
34    DeviceJoinReadiness, DeviceJoinRole, DeviceJoinStatus, DeviceProviderAccessAdministrator,
35    DeviceProviderAccessRequest, DeviceProviderAdmissionApproval,
36    DeviceProviderAdmissionCompletion, DeviceRegistrationRequest, JoinerJoinTerminal,
37    ProviderAdminJoinTerminal, ProvisionalDeviceBootstrap, Store,
38};
39use crate::sync::store_commit::{DeviceJoinAttemptId, ObjectHash, STORE_PROTOCOL_VERSION};
40
41/// The prefix every transport slot's logical key starts with.
42const TRANSPORT_ROOT: &str = "store-v1/device-join-transport";
43
44/// Domain separation for the per-attempt seal, so a sealed artifact cannot be
45/// opened as anything but the kind and attempt it was written for.
46const SEAL_AAD_LABEL: &[u8] = b"coven.device-join-transport.v1";
47
48/// One artifact kind in transit. Every kind has exactly one producing role in
49/// the protocol and exactly one slot per attempt.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case", deny_unknown_fields)]
52pub enum DeviceJoinTransportKind {
53    ProviderAccessRequest,
54    ProviderAdmissionApproval,
55    RegistrationRequest,
56    ProvisionalBootstrap,
57    ProviderReadyBootstrap,
58    Readiness,
59    ProviderAdmissionCompletion,
60    Activation,
61    Abandonment,
62    Cancellation,
63    ProviderAdminTerminal,
64    JoinerTerminal,
65    CleanupReceipt,
66    CleanupActivation,
67}
68
69impl DeviceJoinTransportKind {
70    /// Every kind, in protocol order. An attempt's namespace holds one slot per
71    /// entry — allocated together, deleted together.
72    pub const ALL: [Self; 14] = [
73        Self::ProviderAccessRequest,
74        Self::ProviderAdmissionApproval,
75        Self::RegistrationRequest,
76        Self::ProvisionalBootstrap,
77        Self::ProviderReadyBootstrap,
78        Self::Readiness,
79        Self::ProviderAdmissionCompletion,
80        Self::Activation,
81        Self::Abandonment,
82        Self::Cancellation,
83        Self::ProviderAdminTerminal,
84        Self::JoinerTerminal,
85        Self::CleanupReceipt,
86        Self::CleanupActivation,
87    ];
88
89    /// The last path component of this kind's slot.
90    fn slug(self) -> &'static str {
91        match self {
92            Self::ProviderAccessRequest => "provider-access-request",
93            Self::ProviderAdmissionApproval => "provider-admission-approval",
94            Self::RegistrationRequest => "registration-request",
95            Self::ProvisionalBootstrap => "provisional-bootstrap",
96            Self::ProviderReadyBootstrap => "provider-ready-bootstrap",
97            Self::Readiness => "readiness",
98            Self::ProviderAdmissionCompletion => "provider-admission-completion",
99            Self::Activation => "activation",
100            Self::Abandonment => "abandonment",
101            Self::Cancellation => "cancellation",
102            Self::ProviderAdminTerminal => "provider-admin-terminal",
103            Self::JoinerTerminal => "joiner-terminal",
104            Self::CleanupReceipt => "cleanup-receipt",
105            Self::CleanupActivation => "cleanup-activation",
106        }
107    }
108
109    /// The one role the protocol lets produce this kind. A publish from any
110    /// other role is refused before it reaches storage.
111    fn producer(self) -> DeviceJoinRole {
112        match self {
113            Self::ProviderAccessRequest
114            | Self::RegistrationRequest
115            | Self::Readiness
116            | Self::JoinerTerminal => DeviceJoinRole::Joiner,
117            Self::ProviderAdmissionApproval
118            | Self::ProviderReadyBootstrap
119            | Self::ProviderAdmissionCompletion
120            | Self::ProviderAdminTerminal => DeviceJoinRole::ProviderAdministrator,
121            Self::ProvisionalBootstrap
122            | Self::Activation
123            | Self::Abandonment
124            | Self::Cancellation
125            | Self::CleanupReceipt
126            | Self::CleanupActivation => DeviceJoinRole::Owner,
127        }
128    }
129
130    /// The kind an action's artifact belongs in, or `None` for the actions that
131    /// name local work rather than a transfer (`CompleteJoin`,
132    /// `CompleteCleanup`, `ResumeOperation`) and for the offer, which travels
133    /// out of band.
134    fn of(action: &DeviceJoinAction) -> Option<Self> {
135        match action {
136            DeviceJoinAction::TransferProviderAccessRequest(_) => Some(Self::ProviderAccessRequest),
137            DeviceJoinAction::TransferProviderAdmissionApproval(_) => {
138                Some(Self::ProviderAdmissionApproval)
139            }
140            DeviceJoinAction::TransferRegistrationRequest(_) => Some(Self::RegistrationRequest),
141            DeviceJoinAction::TransferProvisionalBootstrap(_) => Some(Self::ProvisionalBootstrap),
142            DeviceJoinAction::TransferProviderReadyBootstrap(_) => {
143                Some(Self::ProviderReadyBootstrap)
144            }
145            DeviceJoinAction::TransferReadiness(_) => Some(Self::Readiness),
146            DeviceJoinAction::TransferProviderAdmissionCompletion(_) => {
147                Some(Self::ProviderAdmissionCompletion)
148            }
149            DeviceJoinAction::TransferActivation(_) => Some(Self::Activation),
150            DeviceJoinAction::TransferAbandonment(_) => Some(Self::Abandonment),
151            DeviceJoinAction::TransferCancellation(_) => Some(Self::Cancellation),
152            DeviceJoinAction::TransferProviderAdminTerminal(_) => Some(Self::ProviderAdminTerminal),
153            DeviceJoinAction::TransferJoinerTerminal(_) => Some(Self::JoinerTerminal),
154            DeviceJoinAction::TransferCleanupReceipt(_) => Some(Self::CleanupReceipt),
155            DeviceJoinAction::TransferCleanupActivation(_) => Some(Self::CleanupActivation),
156            DeviceJoinAction::TransferOffer(_)
157            | DeviceJoinAction::CompleteJoin(_)
158            | DeviceJoinAction::CompleteCleanup(_)
159            | DeviceJoinAction::ResumeOperation { .. } => None,
160        }
161    }
162}
163
164/// The artifact type a kind carries. Awaiting a kind yields exactly this type,
165/// so a caller never re-matches the action enum it just asked for by kind.
166pub trait DeviceJoinArtifact: Sized {
167    const KIND: DeviceJoinTransportKind;
168
169    fn from_action(action: DeviceJoinAction) -> Option<Self>;
170}
171
172macro_rules! device_join_artifact {
173    ($type:ty, $kind:ident, $variant:ident) => {
174        impl DeviceJoinArtifact for $type {
175            const KIND: DeviceJoinTransportKind = DeviceJoinTransportKind::$kind;
176
177            fn from_action(action: DeviceJoinAction) -> Option<Self> {
178                match action {
179                    DeviceJoinAction::$variant(value) => Some(value),
180                    _ => None,
181                }
182            }
183        }
184    };
185}
186
187device_join_artifact!(
188    DeviceProviderAccessRequest,
189    ProviderAccessRequest,
190    TransferProviderAccessRequest
191);
192device_join_artifact!(
193    DeviceProviderAdmissionApproval,
194    ProviderAdmissionApproval,
195    TransferProviderAdmissionApproval
196);
197device_join_artifact!(
198    DeviceRegistrationRequest,
199    RegistrationRequest,
200    TransferRegistrationRequest
201);
202device_join_artifact!(
203    ProvisionalDeviceBootstrap,
204    ProvisionalBootstrap,
205    TransferProvisionalBootstrap
206);
207device_join_artifact!(
208    crate::sync::store::ProviderReadyDeviceBootstrap,
209    ProviderReadyBootstrap,
210    TransferProviderReadyBootstrap
211);
212device_join_artifact!(DeviceJoinReadiness, Readiness, TransferReadiness);
213device_join_artifact!(
214    DeviceProviderAdmissionCompletion,
215    ProviderAdmissionCompletion,
216    TransferProviderAdmissionCompletion
217);
218device_join_artifact!(DeviceJoinActivation, Activation, TransferActivation);
219device_join_artifact!(DeviceJoinAbandonment, Abandonment, TransferAbandonment);
220device_join_artifact!(DeviceJoinCancellation, Cancellation, TransferCancellation);
221device_join_artifact!(
222    ProviderAdminJoinTerminal,
223    ProviderAdminTerminal,
224    TransferProviderAdminTerminal
225);
226device_join_artifact!(JoinerJoinTerminal, JoinerTerminal, TransferJoinerTerminal);
227device_join_artifact!(
228    DeviceJoinCleanupReceipt,
229    CleanupReceipt,
230    TransferCleanupReceipt
231);
232device_join_artifact!(
233    DeviceJoinCleanupActivation,
234    CleanupActivation,
235    TransferCleanupActivation
236);
237
238/// The slots and seal key one attempt's artifacts travel through.
239///
240/// The owner allocates the slots when it begins the join, because on providers
241/// whose exact slots carry an opaque provider locator (Google Drive) a reader
242/// cannot derive a slot from its logical key — the same reason the protocol's
243/// own attempt, outcome, and registration slots are reserved up front and named
244/// in the signed artifact that precedes them.
245#[derive(Clone, Debug, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct DeviceJoinTransportParams {
248    pub version: u32,
249    pub attempt_namespace: String,
250    pub slots: BTreeMap<DeviceJoinTransportKind, ObjectSlot>,
251    #[serde(with = "seal_key")]
252    pub seal_key: MasterKeyring,
253}
254
255/// `MasterKeyring` is the codebase's symmetric-key carrier and travels as its
256/// own serialized form; the transport adds no second key encoding.
257mod seal_key {
258    use super::MasterKeyring;
259    use serde::{Deserialize, Deserializer, Serializer};
260
261    pub(super) fn serialize<S: Serializer>(
262        keyring: &MasterKeyring,
263        serializer: S,
264    ) -> Result<S::Ok, S::Error> {
265        serializer.serialize_str(&keyring.to_serialized())
266    }
267
268    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
269        deserializer: D,
270    ) -> Result<MasterKeyring, D::Error> {
271        let encoded = String::deserialize(deserializer)?;
272        MasterKeyring::from_serialized(&encoded).map_err(serde::de::Error::custom)
273    }
274}
275
276impl DeviceJoinTransportParams {
277    /// Reserve one slot per kind under this attempt's namespace and mint the
278    /// attempt's seal key.
279    async fn allocate(
280        storage: &dyn SyncStorage,
281        offer: &DeviceJoinOffer,
282    ) -> Result<Self, DeviceJoinTransportError> {
283        let attempt_namespace = attempt_namespace(offer.attempt_id);
284        let context = slot_context(offer.store_root.store_root_hash);
285        let mut slots = BTreeMap::new();
286        for kind in DeviceJoinTransportKind::ALL {
287            let slot = storage
288                .allocate_protocol_slot(
289                    &context,
290                    &semantic_prefix(&attempt_namespace, kind),
291                    ".json",
292                )
293                .await?;
294            slots.insert(kind, slot);
295        }
296        Ok(Self {
297            version: STORE_PROTOCOL_VERSION,
298            attempt_namespace,
299            slots,
300            seal_key: MasterKeyring::generate(),
301        })
302    }
303
304    fn slot(&self, kind: DeviceJoinTransportKind) -> Result<&ObjectSlot, DeviceJoinTransportError> {
305        self.slots
306            .get(&kind)
307            .ok_or(DeviceJoinTransportError::MissingSlot { kind })
308    }
309
310    fn validate_for(&self, offer: &DeviceJoinOffer) -> Result<(), DeviceJoinTransportError> {
311        if self.version != STORE_PROTOCOL_VERSION
312            || self.attempt_namespace != attempt_namespace(offer.attempt_id)
313        {
314            return Err(DeviceJoinTransportError::BundleMismatch);
315        }
316        let context = slot_context(offer.store_root.store_root_hash);
317        for kind in DeviceJoinTransportKind::ALL {
318            context.validate_slot(
319                self.slot(kind)?,
320                &semantic_prefix(&self.attempt_namespace, kind),
321            )?;
322        }
323        Ok(())
324    }
325}
326
327/// The out-of-band kickoff: the offer plus everything the transport needs to
328/// carry the rest of the exchange. The host encodes this however it delivers a
329/// join code; coven does not choose that encoding.
330#[derive(Clone, Debug, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct DeviceJoinOfferBundle {
333    pub version: u32,
334    pub offer: DeviceJoinOffer,
335    pub transport: DeviceJoinTransportParams,
336}
337
338impl DeviceJoinOfferBundle {
339    /// Mint a bundle for an offer the owner has just made: allocate the
340    /// attempt's slots and its seal key.
341    pub async fn allocate(
342        storage: &dyn SyncStorage,
343        offer: DeviceJoinOffer,
344    ) -> Result<Self, DeviceJoinTransportError> {
345        let transport = DeviceJoinTransportParams::allocate(storage, &offer).await?;
346        Ok(Self {
347            version: STORE_PROTOCOL_VERSION,
348            offer,
349            transport,
350        })
351    }
352
353    pub fn to_bytes(&self) -> Vec<u8> {
354        serde_json::to_vec(self).expect("device join offer bundle serialization cannot fail")
355    }
356
357    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DeviceJoinTransportError> {
358        let bundle: Self = serde_json::from_slice(bytes)?;
359        if bundle.version != STORE_PROTOCOL_VERSION {
360            return Err(DeviceJoinTransportError::BundleMismatch);
361        }
362        bundle.transport.validate_for(&bundle.offer)?;
363        Ok(bundle)
364    }
365}
366
367/// What a joining device found while waiting for its next artifact: the
368/// artifact, or the owner's abandonment of the attempt.
369#[derive(Clone, Debug, PartialEq, Eq)]
370pub enum DeviceJoinStep<T> {
371    Continue(T),
372    Abandoned(DeviceJoinAbandonment),
373}
374
375/// How a driven join ended for the admitting side.
376#[derive(Clone, Debug, PartialEq, Eq)]
377pub enum DeviceJoinDriveOutcome {
378    Activated(DeviceJoinActivation),
379    Abandoned(DeviceJoinAbandonment),
380}
381
382/// How often to look for a counterpart's artifact, and how long to keep
383/// looking before giving up on it.
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub struct DeviceJoinTransportTiming {
386    pub poll: Duration,
387    pub deadline: Duration,
388}
389
390/// Why a transfer through the transport failed.
391#[derive(Debug, thiserror::Error)]
392pub enum DeviceJoinTransportError {
393    #[error("storage: {0}")]
394    Storage(#[from] StorageError),
395    #[error("device join: {0}")]
396    DeviceJoin(#[from] DeviceJoinError),
397    #[error("transport artifact is not valid JSON: {0}")]
398    Malformed(#[from] serde_json::Error),
399    #[error("transport artifact could not be unsealed: {0}")]
400    Unsealable(#[from] SealError),
401    #[error("the offer bundle does not describe this attempt's transport")]
402    BundleMismatch,
403    #[error("this attempt's transport has no {kind:?} slot")]
404    MissingSlot { kind: DeviceJoinTransportKind },
405    /// The action carries no transferable artifact — the offer travels out of
406    /// band, and `CompleteJoin`/`CompleteCleanup`/`ResumeOperation` name local
407    /// work rather than a transfer.
408    #[error("{0:?} carries nothing for the transport to deliver")]
409    NotTransferable(Box<DeviceJoinAction>),
410    /// Only one role produces each kind, and this device does not hold it.
411    #[error("a {kind:?} artifact is the {role:?}'s to publish, not this device's")]
412    WrongProducer {
413        kind: DeviceJoinTransportKind,
414        role: DeviceJoinRole,
415    },
416    /// The slot already holds a different artifact of this kind. Republishing
417    /// the same artifact after a crash succeeds; a different one never
418    /// overwrites what a counterpart may already have read.
419    #[error("the {kind:?} slot already holds a different artifact")]
420    ArtifactConflict { kind: DeviceJoinTransportKind },
421    /// The slot's stored bytes are not the ones this write produced — a
422    /// concurrent writer reached it first.
423    #[error("the {kind:?} slot was written concurrently with different bytes")]
424    SlotConflict { kind: DeviceJoinTransportKind },
425    /// The unsealed bytes decode as a different kind than the slot they sat in.
426    #[error("the {kind:?} slot holds an artifact of another kind")]
427    KindMismatch { kind: DeviceJoinTransportKind },
428    #[error("the {producer:?} never published its {kind:?} artifact")]
429    Timeout {
430        kind: DeviceJoinTransportKind,
431        producer: DeviceJoinRole,
432    },
433}
434
435/// The roles one device plays in an attempt. Both admitting roles are usually
436/// the same device; the joining device holds only its own.
437#[derive(Clone, Copy, Debug, PartialEq, Eq)]
438pub struct DeviceJoinRoles {
439    pub owner: bool,
440    pub provider_administrator: bool,
441    pub joiner: bool,
442}
443
444impl DeviceJoinRoles {
445    pub fn joiner() -> Self {
446        Self {
447            owner: false,
448            provider_administrator: false,
449            joiner: true,
450        }
451    }
452
453    pub fn admitting(owner: bool, provider_administrator: bool) -> Self {
454        Self {
455            owner,
456            provider_administrator,
457            joiner: false,
458        }
459    }
460
461    fn holds(self, role: DeviceJoinRole) -> bool {
462        match role {
463            DeviceJoinRole::Owner => self.owner,
464            DeviceJoinRole::ProviderAdministrator => self.provider_administrator,
465            DeviceJoinRole::Joiner => self.joiner,
466        }
467    }
468
469    fn any(self) -> bool {
470        self.owner || self.provider_administrator || self.joiner
471    }
472}
473
474/// One attempt's slot namespace, bound to the roles this device plays in it.
475pub struct DeviceJoinTransport<'a> {
476    storage: &'a dyn SyncStorage,
477    params: &'a DeviceJoinTransportParams,
478    store_root_hash: ObjectHash,
479    seal: EncryptionService,
480    roles: DeviceJoinRoles,
481}
482
483impl<'a> DeviceJoinTransport<'a> {
484    /// Open the transport described by `bundle` against `storage`, for the
485    /// roles this device plays. It may publish only the kinds those roles
486    /// produce; it may read every kind.
487    pub fn open(
488        storage: &'a dyn SyncStorage,
489        bundle: &'a DeviceJoinOfferBundle,
490        roles: DeviceJoinRoles,
491    ) -> Result<Self, DeviceJoinTransportError> {
492        bundle.transport.validate_for(&bundle.offer)?;
493        Ok(Self {
494            storage,
495            params: &bundle.transport,
496            store_root_hash: bundle.offer.store_root.store_root_hash,
497            seal: EncryptionService::from(bundle.transport.seal_key.clone()),
498            roles,
499        })
500    }
501
502    /// Seal an artifact and create it at its slot.
503    ///
504    /// Republishing an artifact already at its slot succeeds — that is what a
505    /// crash between the durable journal advance and the create resumes into.
506    /// The seal draws a fresh nonce per call, so sameness is decided on the
507    /// artifact, not on the stored ciphertext; the first write's bytes stay.
508    /// A *different* artifact at an occupied slot is refused: a counterpart may
509    /// already have read what is there.
510    pub async fn publish(&self, action: &DeviceJoinAction) -> Result<(), DeviceJoinTransportError> {
511        let kind = DeviceJoinTransportKind::of(action)
512            .ok_or_else(|| DeviceJoinTransportError::NotTransferable(Box::new(action.clone())))?;
513        let producer = kind.producer();
514        if !self.roles.holds(producer) {
515            return Err(DeviceJoinTransportError::WrongProducer {
516                kind,
517                role: producer,
518            });
519        }
520        if let Some(existing) = self.read(kind).await? {
521            return if existing == *action {
522                Ok(())
523            } else {
524                Err(DeviceJoinTransportError::ArtifactConflict { kind })
525            };
526        }
527        let sealed = self
528            .seal
529            .seal_app_data(&serde_json::to_vec(action)?, &self.seal_aad(kind));
530        let prepared = self.storage.prepare_protocol_object(
531            &slot_context(self.store_root_hash),
532            self.params.slot(kind)?.clone(),
533            &self.semantic_prefix(kind),
534            sealed,
535        )?;
536        self.storage
537            .create_protocol_object(&prepared)
538            .await
539            .map_err(|error| match error {
540                StorageError::SlotCollision(_) => DeviceJoinTransportError::SlotConflict { kind },
541                other => other.into(),
542            })
543    }
544
545    /// Read one kind's artifact, or `None` while its slot is still empty.
546    pub async fn read(
547        &self,
548        kind: DeviceJoinTransportKind,
549    ) -> Result<Option<DeviceJoinAction>, DeviceJoinTransportError> {
550        let sealed = match self
551            .storage
552            .read_protocol_slot(
553                &slot_context(self.store_root_hash),
554                self.params.slot(kind)?,
555                &self.semantic_prefix(kind),
556            )
557            .await
558        {
559            Ok((sealed, _)) => sealed,
560            Err(StorageError::NotFound(_)) => return Ok(None),
561            Err(error) => return Err(error.into()),
562        };
563        let opened = self.seal.open_app_data(&sealed, &self.seal_aad(kind))?;
564        let action: DeviceJoinAction = serde_json::from_slice(&opened)?;
565        if DeviceJoinTransportKind::of(&action) != Some(kind) {
566            return Err(DeviceJoinTransportError::KindMismatch { kind });
567        }
568        Ok(Some(action))
569    }
570
571    /// Poll for the counterpart's artifact of type `T` until the deadline. The
572    /// timeout names the role that never published, so a host can tell the user
573    /// which device it is waiting on.
574    pub async fn await_artifact<T: DeviceJoinArtifact>(
575        &self,
576        timing: DeviceJoinTransportTiming,
577    ) -> Result<T, DeviceJoinTransportError> {
578        let kind = T::KIND;
579        let polled = tokio::time::timeout(timing.deadline, async {
580            loop {
581                if let Some(action) = self.read(kind).await? {
582                    return T::from_action(action)
583                        .ok_or(DeviceJoinTransportError::KindMismatch { kind });
584                }
585                tokio::time::sleep(timing.poll).await;
586            }
587        })
588        .await;
589        match polled {
590            Ok(artifact) => artifact,
591            Err(_) => Err(DeviceJoinTransportError::Timeout {
592                kind,
593                producer: kind.producer(),
594            }),
595        }
596    }
597
598    /// Poll for the next artifact of type `T`, or for the owner's abandonment
599    /// of the whole attempt, whichever appears first.
600    ///
601    /// The owner may give up on an attempt while the joining device is waiting
602    /// for the next step, so every joiner wait watches both slots. A wait that
603    /// watched only its own kind would sit until its deadline against an
604    /// abandonment already published.
605    pub async fn await_step<T: DeviceJoinArtifact>(
606        &self,
607        timing: DeviceJoinTransportTiming,
608    ) -> Result<DeviceJoinStep<T>, DeviceJoinTransportError> {
609        let kind = T::KIND;
610        let polled = tokio::time::timeout(timing.deadline, async {
611            loop {
612                if let Some(action) = self.read(DeviceJoinTransportKind::Abandonment).await? {
613                    return DeviceJoinAbandonment::from_action(action)
614                        .map(DeviceJoinStep::Abandoned)
615                        .ok_or(DeviceJoinTransportError::KindMismatch {
616                            kind: DeviceJoinTransportKind::Abandonment,
617                        });
618                }
619                if let Some(action) = self.read(kind).await? {
620                    return T::from_action(action)
621                        .map(DeviceJoinStep::Continue)
622                        .ok_or(DeviceJoinTransportError::KindMismatch { kind });
623                }
624                tokio::time::sleep(timing.poll).await;
625            }
626        })
627        .await;
628        match polled {
629            Ok(step) => step,
630            Err(_) => Err(DeviceJoinTransportError::Timeout {
631                kind,
632                producer: kind.producer(),
633            }),
634        }
635    }
636
637    /// Remove every slot this attempt reserved.
638    ///
639    /// Called once the exchange has reached an end the joining device has
640    /// consumed — its completed join, its accepted abandonment, or its accepted
641    /// cleanup activation. The joining device is the last reader on all three,
642    /// which is why the deletion is its to make: the owner has no artifact by
643    /// which it could learn that the joiner read the last thing it published.
644    /// There is no sweep behind this — an attempt that reaches none of those
645    /// ends keeps its slots until its cancellation removes them.
646    pub async fn delete_attempt_slots(&self) -> Result<(), DeviceJoinTransportError> {
647        for kind in DeviceJoinTransportKind::ALL {
648            let prepared = match self
649                .storage
650                .read_prepared_protocol_slot(
651                    &slot_context(self.store_root_hash),
652                    self.params.slot(kind)?,
653                    &self.semantic_prefix(kind),
654                )
655                .await
656            {
657                Ok((_, prepared)) => prepared,
658                Err(StorageError::NotFound(_)) => continue,
659                Err(error) => return Err(error.into()),
660            };
661            self.storage
662                .delete_protocol_object(prepared.reference())
663                .await?;
664        }
665        Ok(())
666    }
667
668    fn semantic_prefix(&self, kind: DeviceJoinTransportKind) -> String {
669        semantic_prefix(&self.params.attempt_namespace, kind)
670    }
671
672    /// Bind a sealed artifact to its store, its attempt, and its kind, so bytes
673    /// lifted from one slot cannot be opened as another.
674    fn seal_aad(&self, kind: DeviceJoinTransportKind) -> Vec<u8> {
675        let prefix = self.semantic_prefix(kind);
676        let mut aad = SEAL_AAD_LABEL.to_vec();
677        aad.extend_from_slice(self.store_root_hash.as_bytes());
678        aad.extend_from_slice(&(prefix.len() as u64).to_le_bytes());
679        aad.extend_from_slice(prefix.as_bytes());
680        aad
681    }
682}
683
684fn attempt_namespace(attempt_id: DeviceJoinAttemptId) -> String {
685    format!("{TRANSPORT_ROOT}/{attempt_id}")
686}
687
688fn semantic_prefix(attempt_namespace: &str, kind: DeviceJoinTransportKind) -> String {
689    format!("{attempt_namespace}/{}", kind.slug())
690}
691
692fn slot_context(store_root_hash: ObjectHash) -> ProtocolObjectContext {
693    ProtocolObjectContext::recipient_sealed(
694        store_root_hash,
695        ProtocolObjectDomain::DeviceJoinTransport,
696    )
697}
698
699/// Whether the driver approves an access request, and on whose say-so.
700pub enum DeviceJoinApprovalPolicy<'a> {
701    /// Approve requests against an attempt this device itself issued: its own
702    /// owner journal holds the attempt, and the request carries the offer this
703    /// bundle names. Anything else is refused. The host opts into this; it is
704    /// never the implicit behavior.
705    AutoApproveSelfIssued,
706    /// Ask the host, which prompts whoever is at the device.
707    Ask(&'a (dyn Fn(&DeviceProviderAccessRequest) -> DeviceJoinApproval + Send + Sync)),
708}
709
710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
711pub enum DeviceJoinApproval {
712    Approve,
713    Refuse,
714}
715
716/// Advance the Owner and Provider Administrator journals as the joiner's
717/// artifacts arrive, publishing each artifact this device produces.
718///
719/// The two roles are usually the same device, and then this drives the whole
720/// admitting side. When they are separate devices, each runs this with its own
721/// keys: the role checks below find only that device's work, and every artifact
722/// it does not produce it waits for in the transport instead.
723///
724/// Returns when the attempt reaches an end the admitting side owns: its
725/// activation, or the abandonment that ends it early. The joiner's completion
726/// is the joiner's own step.
727pub async fn drive_device_join(
728    store: &Store,
729    identity_signer: &UserKeypair,
730    bundle: &DeviceJoinOfferBundle,
731    policy: DeviceJoinApprovalPolicy<'_>,
732    access_administrator: Option<&dyn DeviceProviderAccessAdministrator>,
733    timing: DeviceJoinTransportTiming,
734) -> Result<DeviceJoinDriveOutcome, DeviceJoinTransportError> {
735    let roles = driver_roles(store, &bundle.offer).await?;
736    let transport = DeviceJoinTransport::open(&**store.storage(), bundle, roles)?;
737    let attempt_id = bundle.offer.attempt_id;
738
739    // An abandoned attempt has no further step to drive. Publishing it here is
740    // what lets a driver started after the abandonment still deliver it to a
741    // joining device that has not seen it yet.
742    if let Some(DeviceJoinStatus::Abandoned { abandonment }) =
743        owner_status(store, attempt_id).await?
744    {
745        transport
746            .publish(&DeviceJoinAction::TransferAbandonment(abandonment.clone()))
747            .await?;
748        return Ok(DeviceJoinDriveOutcome::Abandoned(abandonment));
749    }
750
751    // The admitting side produces four artifacts in a fixed order, each after
752    // one of the joiner's. Every phase below starts from its role journal's
753    // durable state rather than from the beginning: a journal already holding
754    // the artifact republishes it (the crash between producing and publishing),
755    // and a journal past it does nothing (its artifact was published, which is
756    // how the journal got past it).
757    if roles.provider_administrator {
758        let approval = match admin_status(store, attempt_id).await? {
759            Some(DeviceJoinStatus::AwaitingRegistrationRequest { approval }) => Some(approval),
760            Some(
761                DeviceJoinStatus::AwaitingChallengePublication { .. }
762                | DeviceJoinStatus::AwaitingReadiness { .. }
763                | DeviceJoinStatus::AwaitingProviderCompletion { .. }
764                | DeviceJoinStatus::AwaitingActivation { .. },
765            ) => None,
766            _ => {
767                let request = transport
768                    .await_artifact::<DeviceProviderAccessRequest>(timing)
769                    .await?;
770                approve_access_request(store, roles, &bundle.offer, &request, &policy).await?;
771                Some(
772                    store
773                        .authorize_device_provider_access(
774                            identity_signer,
775                            request,
776                            access_administrator,
777                        )
778                        .await?,
779                )
780            }
781        };
782        if let Some(approval) = approval {
783            transport
784                .publish(&DeviceJoinAction::TransferProviderAdmissionApproval(
785                    approval,
786                ))
787                .await?;
788        }
789    }
790
791    if roles.owner {
792        let provisional = match owner_status(store, attempt_id).await? {
793            Some(DeviceJoinStatus::AwaitingChallengePublication { bootstrap }) => Some(bootstrap),
794            Some(
795                DeviceJoinStatus::AwaitingActivation { .. }
796                | DeviceJoinStatus::AwaitingCompletion { .. },
797            ) => None,
798            Some(DeviceJoinStatus::AwaitingBootstrap { request }) => Some(
799                store
800                    .accept_device_registration_request(identity_signer, request)
801                    .await?,
802            ),
803            _ => {
804                let request = transport
805                    .await_artifact::<DeviceRegistrationRequest>(timing)
806                    .await?;
807                Some(
808                    store
809                        .accept_device_registration_request(identity_signer, request)
810                        .await?,
811                )
812            }
813        };
814        if let Some(provisional) = provisional {
815            transport
816                .publish(&DeviceJoinAction::TransferProvisionalBootstrap(provisional))
817                .await?;
818        }
819    }
820
821    if roles.provider_administrator {
822        let ready = match admin_status(store, attempt_id).await? {
823            Some(DeviceJoinStatus::AwaitingReadiness { bootstrap }) => Some(bootstrap),
824            Some(
825                DeviceJoinStatus::AwaitingProviderCompletion { .. }
826                | DeviceJoinStatus::AwaitingActivation { .. },
827            ) => None,
828            Some(DeviceJoinStatus::AwaitingChallengePublication { bootstrap }) => {
829                Some(store.publish_device_provider_challenge(bootstrap).await?)
830            }
831            _ => {
832                let provisional = transport
833                    .await_artifact::<ProvisionalDeviceBootstrap>(timing)
834                    .await?;
835                Some(store.publish_device_provider_challenge(provisional).await?)
836            }
837        };
838        if let Some(ready) = ready {
839            transport
840                .publish(&DeviceJoinAction::TransferProviderReadyBootstrap(ready))
841                .await?;
842        }
843
844        let completion = match admin_status(store, attempt_id).await? {
845            Some(DeviceJoinStatus::AwaitingActivation { completion }) => completion,
846            Some(DeviceJoinStatus::AwaitingProviderCompletion { readiness }) => {
847                store
848                    .complete_device_provider_admission(identity_signer, readiness)
849                    .await?
850            }
851            _ => {
852                let readiness = transport
853                    .await_artifact::<DeviceJoinReadiness>(timing)
854                    .await?;
855                store
856                    .complete_device_provider_admission(identity_signer, readiness)
857                    .await?
858            }
859        };
860        transport
861            .publish(&DeviceJoinAction::TransferProviderAdmissionCompletion(
862                completion,
863            ))
864            .await?;
865    }
866
867    if !roles.owner {
868        return Ok(DeviceJoinDriveOutcome::Activated(
869            transport
870                .await_artifact::<DeviceJoinActivation>(timing)
871                .await?,
872        ));
873    }
874
875    let activation = match owner_status(store, attempt_id).await? {
876        Some(DeviceJoinStatus::AwaitingCompletion { activation }) => activation,
877        Some(DeviceJoinStatus::AwaitingActivation { completion }) => {
878            store
879                .finalize_device_join(identity_signer, completion)
880                .await?
881        }
882        _ => {
883            let completion = transport
884                .await_artifact::<DeviceProviderAdmissionCompletion>(timing)
885                .await?;
886            store
887                .finalize_device_join(identity_signer, completion)
888                .await?
889        }
890    };
891    transport
892        .publish(&DeviceJoinAction::TransferActivation(activation.clone()))
893        .await?;
894    Ok(DeviceJoinDriveOutcome::Activated(activation))
895}
896
897/// Give up on an attempt and publish the abandonment, so a joining device
898/// waiting on its next artifact learns the join is over instead of waiting out
899/// its deadline.
900pub async fn abandon_device_join_via_transport(
901    store: &Store,
902    identity_signer: &UserKeypair,
903    bundle: &DeviceJoinOfferBundle,
904) -> Result<DeviceJoinAbandonment, DeviceJoinTransportError> {
905    let roles = driver_roles(store, &bundle.offer).await?;
906    let transport = DeviceJoinTransport::open(&**store.storage(), bundle, roles)?;
907    let abandonment = store
908        .abandon_device_join(identity_signer, bundle.offer.clone())
909        .await?;
910    transport
911        .publish(&DeviceJoinAction::TransferAbandonment(abandonment.clone()))
912        .await?;
913    Ok(abandonment)
914}
915
916async fn admin_status(
917    store: &Store,
918    attempt_id: DeviceJoinAttemptId,
919) -> Result<Option<DeviceJoinStatus>, DeviceJoinTransportError> {
920    Ok(store
921        .database()
922        .device_join_status(attempt_id, DeviceJoinRole::ProviderAdministrator)
923        .await?)
924}
925
926async fn owner_status(
927    store: &Store,
928    attempt_id: DeviceJoinAttemptId,
929) -> Result<Option<DeviceJoinStatus>, DeviceJoinTransportError> {
930    Ok(store
931        .database()
932        .device_join_status(attempt_id, DeviceJoinRole::Owner)
933        .await?)
934}
935
936/// Cancel an attempt and carry the unwind to its activated cleanup.
937///
938/// The joiner's own closure travels through the transport: the owner publishes
939/// the cancellation, waits for the joiner's terminal, and settles the receipt
940/// against both terminals. Like the admitting driver, every step starts from
941/// the role journal's durable state, so a run that died mid-unwind resumes
942/// where it stopped rather than replaying a step its journal is already past.
943///
944/// The attempt's slots are not deleted here. The joining device reads the
945/// cleanup activation this publishes last, and the owner has no artifact by
946/// which it could learn that read had happened — so the deletion belongs to the
947/// joiner, at the point it accepts that activation.
948pub async fn cancel_device_join_via_transport(
949    store: &Store,
950    identity_signer: &UserKeypair,
951    bundle: &DeviceJoinOfferBundle,
952    attempt: crate::sync::store_commit::DeviceJoinAttemptRef,
953    timing: DeviceJoinTransportTiming,
954) -> Result<DeviceJoinCleanupActivation, DeviceJoinTransportError> {
955    let roles = driver_roles(store, &bundle.offer).await?;
956    let transport = DeviceJoinTransport::open(&**store.storage(), bundle, roles)?;
957    let attempt_id = bundle.offer.attempt_id;
958
959    let receipt = match owner_status(store, attempt_id).await? {
960        // The unwind already reached its end; republish what it settled on.
961        Some(DeviceJoinStatus::CleanupActivated { activation }) => {
962            transport
963                .publish(&DeviceJoinAction::TransferCleanupActivation(
964                    activation.clone(),
965                ))
966                .await?;
967            store
968                .complete_owner_device_join_cleanup(activation.clone())
969                .await?;
970            return Ok(activation);
971        }
972        Some(DeviceJoinStatus::AwaitingCleanupActivation { receipt }) => receipt,
973        _ => {
974            let cancellation =
975                cancel_and_publish(store, identity_signer, &transport, attempt_id, attempt).await?;
976            let administrator_terminal = store
977                .close_device_provider_admission(identity_signer, cancellation.clone())
978                .await?;
979            transport
980                .publish(&DeviceJoinAction::TransferProviderAdminTerminal(
981                    administrator_terminal.clone(),
982                ))
983                .await?;
984            let joiner_terminal = transport
985                .await_artifact::<JoinerJoinTerminal>(timing)
986                .await?;
987            store
988                .prepare_device_join_cleanup(
989                    identity_signer,
990                    cancellation,
991                    administrator_terminal,
992                    joiner_terminal,
993                )
994                .await?
995        }
996    };
997    transport
998        .publish(&DeviceJoinAction::TransferCleanupReceipt(receipt.clone()))
999        .await?;
1000
1001    let activation = store
1002        .activate_device_join_cleanup(identity_signer, receipt)
1003        .await?;
1004    transport
1005        .publish(&DeviceJoinAction::TransferCleanupActivation(
1006            activation.clone(),
1007        ))
1008        .await?;
1009    store
1010        .complete_owner_device_join_cleanup(activation.clone())
1011        .await?;
1012    Ok(activation)
1013}
1014
1015/// The attempt's cancellation, taken from the owner journal when it already
1016/// holds one — `cancel_device_join` refuses to run again once the unwind has
1017/// moved on to preparing the cleanup receipt.
1018async fn cancel_and_publish(
1019    store: &Store,
1020    identity_signer: &UserKeypair,
1021    transport: &DeviceJoinTransport<'_>,
1022    attempt_id: DeviceJoinAttemptId,
1023    attempt: crate::sync::store_commit::DeviceJoinAttemptRef,
1024) -> Result<DeviceJoinCancellation, DeviceJoinTransportError> {
1025    let cancellation = match owner_status(store, attempt_id).await? {
1026        Some(
1027            DeviceJoinStatus::CleanupPending { cancellation, .. }
1028            | DeviceJoinStatus::CleanupReceiptCreatePending { cancellation, .. },
1029        ) => cancellation,
1030        _ => store.cancel_device_join(identity_signer, attempt).await?,
1031    };
1032    transport
1033        .publish(&DeviceJoinAction::TransferCancellation(
1034            cancellation.clone(),
1035        ))
1036        .await?;
1037    Ok(cancellation)
1038}
1039
1040async fn driver_roles(
1041    store: &Store,
1042    offer: &DeviceJoinOffer,
1043) -> Result<DeviceJoinRoles, DeviceJoinTransportError> {
1044    let local = store
1045        .database()
1046        .local_activated_registration_ref()
1047        .await
1048        .map_err(|error| DeviceJoinError::Store(error.to_string()))?
1049        .ok_or(DeviceJoinError::ActiveDeviceRequired)?;
1050    let roles = DeviceJoinRoles::admitting(
1051        local == offer.owner_registration,
1052        local == offer.provider_admin.administrator,
1053    );
1054    if !roles.any() {
1055        return Err(DeviceJoinError::ActiveDeviceRequired.into());
1056    }
1057    Ok(roles)
1058}
1059
1060async fn approve_access_request(
1061    store: &Store,
1062    roles: DeviceJoinRoles,
1063    offer: &DeviceJoinOffer,
1064    request: &DeviceProviderAccessRequest,
1065    policy: &DeviceJoinApprovalPolicy<'_>,
1066) -> Result<(), DeviceJoinTransportError> {
1067    let approval = match policy {
1068        DeviceJoinApprovalPolicy::AutoApproveSelfIssued => {
1069            if self_issued(store, roles, offer).await? && *request.offer == *offer {
1070                DeviceJoinApproval::Approve
1071            } else {
1072                DeviceJoinApproval::Refuse
1073            }
1074        }
1075        DeviceJoinApprovalPolicy::Ask(ask) => ask(request),
1076    };
1077    match approval {
1078        DeviceJoinApproval::Approve => Ok(()),
1079        DeviceJoinApproval::Refuse => Err(DeviceJoinError::OfferMismatch.into()),
1080    }
1081}
1082
1083/// Whether this device issued the offer being admitted — the bound
1084/// `AutoApproveSelfIssued` keeps to.
1085///
1086/// Two facts decide it, both authoritative: this device is the offer's owner,
1087/// and its own owner journal holds a record for this attempt. That record
1088/// exists only because this device ran `begin_device_join` for it. A provider
1089/// administrator that is a *different* device never satisfies this, so it
1090/// prompts rather than admitting an offer it did not make.
1091async fn self_issued(
1092    store: &Store,
1093    roles: DeviceJoinRoles,
1094    offer: &DeviceJoinOffer,
1095) -> Result<bool, DeviceJoinTransportError> {
1096    if !roles.owner {
1097        return Ok(false);
1098    }
1099    Ok(store
1100        .database()
1101        .device_join_status(offer.attempt_id, DeviceJoinRole::Owner)
1102        .await?
1103        .is_some())
1104}