Skip to main content

aven_core/sync/client/enrollment/
management.rs

1//! Removal, withdrawal of an expired possibly disclosed invitation, and one
2//! bounded automatic finish, using the protected candidate owner.
3use super::*;
4use crate::sync::client::keys::{peer::Disclosure, rotation::Action};
5use crate::sync::seed_claim::membership::{CancelStatus, MAX_CUTOFF};
6
7#[derive(Debug, PartialEq, Eq)]
8pub enum RemovalStatus {
9    Complete,
10    Pending,
11    /// Confirmed in-flight self-Revoke; a survivor must finish rotation.
12    SelfRevoked,
13}
14impl Client {
15    /// Remove one active device and attempt a bounded rotation for the survivors.
16    /// Self-removal can confirm only its in-flight acknowledgement; retired
17    /// credentials cannot resolve a lost reply. Local data and keys are retained.
18    pub async fn remove_device(
19        &self,
20        store: &ProtectedLocalKeyStore,
21        db: &Database,
22        target: [u8; 32],
23    ) -> Result<RemovalStatus> {
24        Box::pin(self.manage(store, db, Some(target), None)).await
25    }
26    pub async fn finish_pending_management(
27        &self,
28        store: &ProtectedLocalKeyStore,
29        db: &Database,
30    ) -> Result<()> {
31        self.finish_pending_removal(store, db).await?;
32        Box::pin(self.withdraw_expired_disclosure(store, db)).await
33    }
34    /// Continues a retained removal or rotation.
35    pub async fn finish_pending_removal(
36        &self,
37        store: &ProtectedLocalKeyStore,
38        db: &Database,
39    ) -> Result<()> {
40        Box::pin(self.manage(store, db, None, None)).await?;
41        Ok(())
42    }
43    pub(crate) async fn refresh_and_finish_pending_removal(
44        &self,
45        store: &ProtectedLocalKeyStore,
46        db: &Database,
47    ) -> Result<()> {
48        let mut inputs = store.active_inputs(db, &self.locator).await?;
49        self.refresh_inputs(store, db, &mut inputs).await?;
50        Box::pin(self.manage_inputs(store, db, inputs, None, None)).await?;
51        Ok(())
52    }
53    async fn management_preparation(
54        &self,
55        store: &ProtectedLocalKeyStore,
56        db: &Database,
57        inputs: &mut ActiveInputs,
58    ) -> Result<u64> {
59        let Reply::PreparedManagement(prepared) = self
60            .exchange(
61                Operation::PrepareManagement {
62                    context: Context::active(inputs),
63                },
64                Some(inputs.bearer()),
65            )
66            .await?
67        else {
68            anyhow::bail!("error management-response");
69        };
70        let membership = prepared.evidence.verify()?;
71        ensure!(
72            membership.head() == inputs.membership.head()
73                && prepared.high_water >= membership.current_generation().starts_after
74                && prepared.high_water >= db.encrypted_tail_observed_rank().await?
75                && prepared.high_water <= MAX_CUTOFF,
76            "error management-context"
77        );
78        store
79            .adopt_verified_refresh(db, inputs, prepared.evidence, membership)
80            .await?;
81        Ok(prepared.high_water)
82    }
83    /// Ends an expired invitation whose grant may have been sent. Admission
84    /// and withdrawal are decided only from verified membership: an admitted
85    /// candidate finishes `ready`; otherwise the local fence and server
86    /// cancellation precede a targetless freeze and rotation, and `withdrawn`
87    /// needs the chain proof. Expiry never proves withdrawal by itself.
88    pub async fn withdraw_expired_disclosure(
89        &self,
90        store: &ProtectedLocalKeyStore,
91        db: &Database,
92    ) -> Result<()> {
93        let handle = {
94            let mut inputs = store.active_inputs(db, &self.locator).await?;
95            let Some(journal) = store.expired_disclosure(db, &inputs).await? else {
96                return Ok(());
97            };
98            self.refresh_inputs(store, db, &mut inputs).await?;
99            if store.reconcile_disclosure(db, &inputs, &journal).await? != Disclosure::Unresolved {
100                return Ok(());
101            }
102            store.mark_withdrawing(db, &inputs, &journal).await?;
103            let status = self.cancel(store, db, &mut inputs, journal.handle).await?;
104            self.refresh_inputs(store, db, &mut inputs).await?;
105            if store.reconcile_disclosure(db, &inputs, &journal).await? != Disclosure::Unresolved {
106                return Ok(());
107            }
108            ensure!(
109                status == CancelStatus::Cancelled,
110                "error withdrawal-admission-unverified"
111            );
112            store
113                .management_intent(db, &inputs, None, Some(journal.handle))
114                .await?;
115            journal.handle
116        };
117        Box::pin(self.manage(store, db, None, Some(handle))).await?;
118        let mut inputs = store.active_inputs(db, &self.locator).await?;
119        self.refresh_inputs(store, db, &mut inputs).await?;
120        if let Some(journal) = store.expired_disclosure(db, &inputs).await? {
121            store.reconcile_disclosure(db, &inputs, &journal).await?;
122        }
123        Ok(())
124    }
125    pub async fn cancel(
126        &self,
127        store: &ProtectedLocalKeyStore,
128        db: &Database,
129        inputs: &mut ActiveInputs,
130        handle: [u8; 32],
131    ) -> Result<CancelStatus> {
132        let reply = retry_stale!(
133            self.exchange(
134                Operation::Cancel {
135                    context: Context::active(inputs),
136                    handle,
137                },
138                Some(inputs.bearer()),
139            )
140            .await,
141            self.refresh_inputs(store, db, inputs).await,
142        )?;
143        match reply {
144            Reply::Cancelled(status) => Ok(status),
145            _ => anyhow::bail!("error enrollment-response"),
146        }
147    }
148    pub async fn manage(
149        &self,
150        store: &ProtectedLocalKeyStore,
151        db: &Database,
152        target: Option<[u8; 32]>,
153        withdraw: Option<[u8; 32]>,
154    ) -> Result<RemovalStatus> {
155        let mut inputs = store.active_inputs(db, &self.locator).await?;
156        if target.is_none()
157            && withdraw.is_none()
158            && store
159                .management_intent(db, &inputs, None, None)
160                .await?
161                .is_none()
162        {
163            return Ok(RemovalStatus::Complete);
164        }
165        self.refresh_inputs(store, db, &mut inputs).await?;
166        Box::pin(self.manage_inputs(store, db, inputs, target, withdraw)).await
167    }
168    async fn manage_inputs(
169        &self,
170        store: &ProtectedLocalKeyStore,
171        db: &Database,
172        mut inputs: ActiveInputs,
173        target: Option<[u8; 32]>,
174        withdraw: Option<[u8; 32]>,
175    ) -> Result<RemovalStatus> {
176        let Some(intent) = store
177            .management_intent(db, &inputs, target, withdraw)
178            .await?
179        else {
180            return Ok(RemovalStatus::Complete);
181        };
182        let mut stale_retry = false;
183        let mut rotation_attempts = 0;
184        // Revoke, then Rotate, with at most one additional stale-context attempt.
185        for _ in 0..3 {
186            let result = self.management_preparation(store, db, &mut inputs).await;
187            let high = match result {
188                Ok(high) => high,
189                Err(error) if is_stale(&error) && !stale_retry => {
190                    stale_retry = true;
191                    self.refresh_inputs(store, db, &mut inputs).await?;
192                    continue;
193                }
194                Err(error) => return Err(error),
195            };
196            let Some(dispatch) = store
197                .management_dispatch(db, &inputs, &intent, high)
198                .await?
199            else {
200                store
201                    .management_intent(db, &inputs, target, withdraw)
202                    .await?;
203                return Ok(RemovalStatus::Complete);
204            };
205            if dispatch.action == Action::Rotate {
206                rotation_attempts += 1;
207                ensure!(rotation_attempts <= 2, "error management-budget");
208            }
209            match self
210                .exchange(
211                    Operation::Manage {
212                        context: Context::active(&inputs),
213                        record: dispatch.record.clone(),
214                    },
215                    Some(inputs.bearer()),
216                )
217                .await
218            {
219                Ok(Reply::Managed(record)) => {
220                    ensure!(
221                        record == dispatch.record,
222                        "error management-outcome-mismatch"
223                    );
224                    if intent.target() == Some(inputs.device()) && dispatch.action == Action::Revoke
225                    {
226                        let result = store
227                            .adopt_verified_refresh(
228                                db,
229                                &mut inputs,
230                                dispatch.evidence,
231                                dispatch.membership,
232                            )
233                            .await;
234                        ensure!(
235                            result.as_ref().err().is_some_and(|e| {
236                                crate::sync::client::errors::has_code(e, "enrollment-revoked")
237                            }),
238                            "error management-self-revoke"
239                        );
240                        return Ok(RemovalStatus::SelfRevoked);
241                    }
242                    self.refresh_inputs(store, db, &mut inputs).await?;
243                    if store
244                        .management_intent(db, &inputs, target, withdraw)
245                        .await?
246                        .is_none()
247                    {
248                        return Ok(RemovalStatus::Complete);
249                    }
250                    if dispatch.action == Action::Rotate {
251                        return Ok(RemovalStatus::Pending);
252                    }
253                }
254                Err(error) if is_stale(&error) && !stale_retry => {
255                    stale_retry = true;
256                    self.refresh_inputs(store, db, &mut inputs).await?;
257                }
258                Err(error) => return Err(error),
259                _ => anyhow::bail!("error management-response"),
260            }
261        }
262        Ok(RemovalStatus::Pending)
263    }
264}