Skip to main content

aven_core/sync/client/engine/
devices.rs

1//! Listing and removing the devices that take part in sync.
2//!
3//! Both operations refresh verified membership from the server first. Removal
4//! delegates to the engine's durable removal and rotation; rerunning it
5//! resumes a retained removal instead of starting another.
6use anyhow::{Result, bail, ensure};
7
8use super::{
9    NOT_SET_UP, REFUSED, associated_server, explain_change_limit, is_set_up, track_access_result,
10};
11use crate::db::Database;
12use crate::sync::client::coordination;
13use crate::sync::client::enrollment::{self, RemovalStatus};
14use crate::sync::client::errors::{code, has_code};
15use crate::sync::client::exchange::Link;
16use crate::sync::client::host::{ClientHost, key_store};
17use crate::sync::client::keys::ProtectedLocalKeyStore;
18
19const REMOVED: &str = "error sync-device-removed hint=\"another device removed this device from sync; its local tasks and images stay available here but can no longer sync\"";
20
21fn explain_revoked(error: anyhow::Error) -> anyhow::Error {
22    if has_code(&error, "enrollment-revoked") {
23        error.context(REMOVED)
24    } else {
25        error
26    }
27}
28
29struct Session {
30    _guard: coordination::SyncProcessGuard,
31    store: ProtectedLocalKeyStore,
32    client: enrollment::Client,
33    server: String,
34}
35
36/// Opens an enrolled device for management under the sync coordination lock.
37async fn open(link: Link, database: &Database, host: &dyn ClientHost) -> Result<Session> {
38    host.ensure_sync_allowed()?;
39    ensure!(is_set_up(database).await?, NOT_SET_UP);
40    let store = key_store(host, database).await?;
41    let guard = coordination::acquire(database).await?;
42    let server = associated_server(&store, database).await?;
43    Ok(Session {
44        _guard: guard,
45        client: enrollment::Client::new(&server, link)?,
46        store,
47        server,
48    })
49}
50
51/// One device in verified membership.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct Device {
54    pub id: [u8; 32],
55    pub label: Option<String>,
56    pub current: bool,
57    /// Position of the admission in the membership chain; not a date or a
58    /// device number.
59    pub admission_sequence: u64,
60}
61
62/// Devices in sync, read from membership the server just verified.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct DeviceListing {
65    pub server: String,
66    /// Keys for future changes still need rotating after a removal.
67    pub key_rotation_pending: bool,
68    pub devices: Vec<Device>,
69}
70
71pub async fn load_devices(
72    link: Link,
73    database: &Database,
74    host: &dyn ClientHost,
75) -> Result<DeviceListing> {
76    let Session {
77        _guard,
78        store,
79        client,
80        server,
81    } = open(link, database, host).await?;
82    let mut inputs = store.active_inputs(database, &server).await?;
83    let refreshed = client
84        .refresh_inputs(&store, database, &mut inputs)
85        .await
86        .map_err(|error| match code(&error).as_deref() {
87            Some("enrollment-unauthorized") => error.context(REFUSED),
88            _ => explain_revoked(error),
89        });
90    track_access_result(database, refreshed).await?;
91    let current = inputs.device();
92    let labels = database.device_labels().await?;
93    Ok(DeviceListing {
94        server,
95        key_rotation_pending: inputs.membership.rotation_pending(),
96        devices: inputs
97            .membership
98            .admissions()
99            .map(|(device, sequence)| Device {
100                id: device,
101                label: labels.get(&device).cloned(),
102                current: device == current,
103                admission_sequence: sequence,
104            })
105            .collect(),
106    })
107}
108
109/// Explains engine refusals that do not name their cause. The engine has
110/// already refreshed verified membership when it refuses a target.
111async fn explain_removal_error(
112    store: &ProtectedLocalKeyStore,
113    database: &Database,
114    server: &str,
115    target: [u8; 32],
116    error: anyhow::Error,
117) -> anyhow::Error {
118    let hint = match code(&error).as_deref() {
119        Some("enrollment-revoked") => return error.context(REMOVED),
120        // Preparing a removal refuses a target outside verified membership.
121        Some("membership-signer" | "membership-invalid") => {
122            match store.active_inputs(database, server).await {
123                Ok(inputs) if !inputs.membership.has_device(target) => {
124                    "error sync-device-not-found hint=\"the device is not in sync; list devices with `aven sync device list`\""
125                }
126                _ => return error,
127            }
128        }
129        Some("membership-change-limit") => return explain_change_limit(error),
130        Some("management-unfinished") => {
131            "error sync-device-removal-unfinished hint=\"an earlier device removal from this device is unfinished; run `aven sync` to finish it, then retry\""
132        }
133        Some("enrollment-unauthorized") => {
134            "error sync-device-removal-incomplete hint=\"rerun the same command; it resumes this removal. If the server keeps refusing, another device may have removed this device from sync\""
135        }
136        _ => {
137            "error sync-device-removal-incomplete hint=\"rerun the same command; it resumes this removal\""
138        }
139    };
140    error.context(hint)
141}
142
143/// Result of removing another device, read back from verified membership.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub struct Removal {
146    pub device: [u8; 32],
147    /// The device is no longer in verified membership.
148    pub access_revoked: bool,
149    /// Keys for future changes are not rotated yet.
150    pub key_rotation_pending: bool,
151}
152
153/// Removes another device, or resumes its retained removal. Refuses the
154/// current device before reaching the engine.
155pub async fn remove_other_device(
156    link: Link,
157    database: &Database,
158    host: &dyn ClientHost,
159    target: [u8; 32],
160) -> Result<Removal> {
161    let Session {
162        _guard,
163        store,
164        client,
165        server,
166    } = open(link, database, host).await?;
167    // Leaving from this device would need a separate contract for its own
168    // retired credential and remaining local sync state.
169    let current = store.active_inputs(database, &server).await?.device();
170    ensure!(
171        target != current,
172        "error sync-device-current hint=\"this is the current device; remove it from another device in sync\""
173    );
174    let status = match client.remove_device(&store, database, target).await {
175        Ok(status) => status,
176        Err(error) => {
177            let error = explain_removal_error(&store, database, &server, target, error).await;
178            return track_access_result(database, Err(error)).await;
179        }
180    };
181    let key_rotation_pending = match status {
182        RemovalStatus::Complete => false,
183        RemovalStatus::Pending => true,
184        RemovalStatus::SelfRevoked => bail!("error management-self-revoke"),
185    };
186    let membership = store.active_inputs(database, &server).await?.membership;
187    Ok(Removal {
188        device: target,
189        access_revoked: !membership.has_device(target),
190        key_rotation_pending,
191    })
192}
193
194/// Continues an unfinished removal or key rotation retained by the engine, as
195/// an ordinary sync round would. Returns whether rotation is still pending.
196pub async fn finish_removal(
197    link: Link,
198    database: &Database,
199    host: &dyn ClientHost,
200) -> Result<bool> {
201    let Session {
202        _guard,
203        store,
204        client,
205        server,
206    } = open(link, database, host).await?;
207    let finished = client
208        .finish_pending_management(&store, database)
209        .await
210        .map_err(|error| match code(&error).as_deref() {
211            Some("enrollment-unauthorized") => error.context(REFUSED),
212            _ => explain_revoked(error),
213        });
214    track_access_result(database, finished).await?;
215    Ok(store
216        .active_inputs(database, &server)
217        .await?
218        .membership
219        .rotation_pending())
220}