Skip to main content

bitwarden_shared_unlock/
peer.rs

1//! The single participant type of the shared unlock protocol.
2//!
3//! Every client runs exactly one [`SharedUnlockPeer`]. A peer syncs its lock state to the peer it
4//! recognizes as its leader and to the peers that sync to it.
5//!
6//! A web peer is scoped to the origin it was validated against: it is only ever told about the
7//! users whose vault URL is that origin, in either direction.
8
9use std::{
10    collections::{HashMap, HashSet},
11    sync::{Arc, Mutex},
12    time::Duration,
13};
14
15use bitwarden_core::UserId;
16use bitwarden_error::bitwarden_error;
17use bitwarden_ipc::{
18    Endpoint, IpcClient, IpcClientExt, RequestError, Source, SubscribeError, TypedIncomingMessage,
19};
20use bitwarden_threading::{cancellation_token, time::sleep};
21use thiserror::Error;
22use tracing::warn;
23
24use crate::{
25    DeviceEvent, LockState, SharedUnlockClient, SharedUnlockSync, TimestampedLockState,
26    active_peers::{ActivePeerTracker, SyncTarget},
27    drivers::SharedUnlockDriver,
28    timing::{SharedUnlockTiming, now_millis},
29};
30
31/// Error type for failure to start a shared unlock peer.
32#[bitwarden_error(basic)]
33#[derive(Debug, Error)]
34#[error("Could not start shared unlock peer: {0}")]
35pub struct PeerStartError(#[from] SubscribeError);
36
37/// One participant in the shared unlock protocol.
38pub struct SharedUnlockPeer<D: SharedUnlockDriver>(Arc<InnerPeer<D>>);
39
40impl<D: SharedUnlockDriver> Clone for SharedUnlockPeer<D> {
41    fn clone(&self) -> Self {
42        Self(self.0.clone())
43    }
44}
45
46/// Inner implementation of the peer, containing the actual state and logic. The outer
47/// `SharedUnlockPeer` is a thin wrapper around an `Arc` to allow for shared ownership across async
48/// tasks.
49struct InnerPeer<D: SharedUnlockDriver> {
50    driver: D,
51    ipc_client: Arc<dyn IpcClient>,
52    /// This device's view of each user's lock state.
53    states: Mutex<HashMap<UserId, TimestampedLockState>>,
54    active_peers: ActivePeerTracker,
55    timing: SharedUnlockTiming,
56    /// Per user, the client kinds this peer is allowed to sync that user to. A user is absent
57    /// until [`SharedUnlockPeer::set_destinations`] is called for it, so a user is shared with
58    /// nothing until its client opts in.
59    destinations: Mutex<HashMap<UserId, HashSet<SharedUnlockClient>>>,
60}
61
62impl<D: SharedUnlockDriver + Send + Sync + 'static> SharedUnlockPeer<D> {
63    /// Creates a peer. Nothing is sent until [`SharedUnlockPeer::start`] is called.
64    pub fn create(driver: D, ipc_client: Arc<dyn IpcClient>) -> Self {
65        Self::create_with_timing(driver, ipc_client, SharedUnlockTiming::default())
66    }
67
68    /// Creates a peer that syncs on the given timings rather than the production ones, so tests do
69    /// not have to wait out a 5s interval. Compiled only for tests and under `test-support`.
70    #[cfg(any(test, feature = "test-support"))]
71    pub fn create_for_test(
72        driver: D,
73        ipc_client: Arc<dyn IpcClient>,
74        sync_interval: Duration,
75        vault_timeout_grace_period: Duration,
76        peer_stale_after: Duration,
77    ) -> Self {
78        Self::create_with_timing(
79            driver,
80            ipc_client,
81            SharedUnlockTiming {
82                sync_interval,
83                vault_timeout_grace_period,
84                peer_stale_after,
85            },
86        )
87    }
88
89    fn create_with_timing(
90        driver: D,
91        ipc_client: Arc<dyn IpcClient>,
92        timing: SharedUnlockTiming,
93    ) -> Self {
94        Self(Arc::new(InnerPeer {
95            driver,
96            ipc_client,
97            states: Mutex::new(HashMap::new()),
98            active_peers: ActivePeerTracker::default(),
99            timing,
100            destinations: Mutex::new(HashMap::new()),
101        }))
102    }
103
104    /// Sets which clients this peer shares one user's unlock state with, in both directions: a
105    /// client that is not in the set is never synced that user, and that user's syncs from it are
106    /// dropped on arrival. Replaces any previous set for the user.
107    ///
108    /// Every user defaults to no client at all — a peer neither sends nor accepts anything for a
109    /// user until this is called for it.
110    pub fn set_destinations(&self, user_id: UserId, destinations: Vec<SharedUnlockClient>) {
111        self.0
112            .destinations
113            .lock()
114            .unwrap_or_else(|poisoned| poisoned.into_inner())
115            .insert(user_id, destinations.into_iter().collect());
116    }
117
118    fn is_destination(&self, user_id: UserId, endpoint: &Endpoint) -> bool {
119        self.0
120            .destinations
121            .lock()
122            .unwrap_or_else(|poisoned| poisoned.into_inner())
123            .get(&user_id)
124            .is_some_and(|clients| clients.contains(&SharedUnlockClient::of_endpoint(endpoint)))
125    }
126
127    /// Starts the receive loop and the sync timer, then announces this peer's state once so it does
128    /// not have to wait a full interval to be discovered.
129    pub async fn start(
130        &self,
131        cancellation_token: Option<cancellation_token::CancellationToken>,
132    ) -> Result<(), PeerStartError> {
133        let cancellation_token = cancellation_token.unwrap_or_default();
134        let mut subscription = self
135            .0
136            .ipc_client
137            .subscribe_typed::<SharedUnlockSync>()
138            .await?;
139
140        let peer = self.clone();
141        let receive_token = cancellation_token.clone();
142        let receive_loop = async move {
143            loop {
144                match subscription.receive(Some(receive_token.clone())).await {
145                    Ok(message) => {
146                        if let Err(error) = peer.receive_message(message).await {
147                            tracing::error!(?error, "Failed to handle shared unlock sync");
148                        }
149                    }
150                    Err(bitwarden_ipc::TypedReceiveError::Cancelled) => {
151                        tracing::info!("Shared unlock peer stopped by cancellation");
152                        break;
153                    }
154                    // This is required because otherwise the browser may freeze in this loop
155                    Err(bitwarden_ipc::TypedReceiveError::Channel(
156                        tokio::sync::broadcast::error::RecvError::Closed,
157                    )) => {
158                        tracing::info!("Transport channel closed. Waiting for it to open");
159                        sleep(Duration::from_secs(1)).await;
160                    }
161                    Err(error) => {
162                        tracing::error!(?error, "Failed to receive shared unlock IPC message");
163                    }
164                }
165            }
166        };
167
168        spawn(receive_loop);
169
170        let peer = self.clone();
171        let timer_token = cancellation_token.clone();
172        let timing = self.0.timing;
173        let timer_loop = async move {
174            loop {
175                tokio::select! {
176                    _ = timer_token.cancelled() => {
177                        tracing::debug!("Shared unlock peer timer cancelled");
178                        break;
179                    }
180                    _ = sleep(timing.sync_interval) => {
181                        peer.0.active_peers.prune_stale(timing.peer_stale_after);
182                        peer.sync_all_users().await;
183                    }
184                }
185            }
186        };
187
188        spawn(timer_loop);
189
190        self.sync_all_users().await;
191        Ok(())
192    }
193
194    /// Handles a sync from another peer.
195    pub async fn receive_message(
196        &self,
197        incoming_message: TypedIncomingMessage<SharedUnlockSync>,
198    ) -> Result<(), ()> {
199        let source = incoming_message.source;
200        let SharedUnlockSync { user_id, state } = incoming_message.payload;
201
202        if !self.is_destination(user_id, &source.to_endpoint()) {
203            tracing::debug!(
204                ?source,
205                %user_id,
206                "Ignoring shared unlock sync from a client this user is not shared with"
207            );
208            return Ok(());
209        }
210
211        // Validate the origin of web sources against the user's vault URL
212        if let Source::Web { origin, .. } = &source {
213            match self.0.driver.get_vault_url(user_id).await {
214                Some(user_vault_url) if origin == &user_vault_url => {}
215                Some(user_vault_url) => {
216                    warn!(%origin, %user_vault_url, "IPC message origin does not match user's vault URL, ignoring message");
217                    return Ok(());
218                }
219                None => {
220                    warn!(%origin, "No vault URL found for user, ignoring message");
221                    return Ok(());
222                }
223            }
224        }
225
226        if !self.0.driver.list_users().await.contains(&user_id) {
227            tracing::debug!(
228                %user_id,
229                "Ignoring shared unlock sync for a user this device has no account for"
230            );
231            return Ok(());
232        }
233
234        let target = SyncTarget::from_source(&source);
235        let from_leader = self.0.driver.discover_leader().await.as_ref() == Some(&target.endpoint);
236        if from_leader {
237            // Suppressed before applying, so a lock or unlock that takes seconds to settle cannot
238            // let the previously granted suppression lapse in the meantime.
239            self.0
240                .driver
241                .suppress_vault_timeout(
242                    user_id,
243                    self.0.timing.sync_interval + self.0.timing.vault_timeout_grace_period,
244                )
245                .await;
246        }
247
248        let first_contact = self.0.active_peers.upsert(&target);
249        self.apply_remote_state(user_id, state).await?;
250
251        if first_contact {
252            self.sync_all_users_to(&target).await;
253        }
254
255        Ok(())
256    }
257
258    /// Records a lock state change made on this device and syncs it to every peer immediately.
259    pub async fn handle_device_event(&self, event: DeviceEvent) -> Result<(), ()> {
260        let (user_id, lock_state) = match &event {
261            DeviceEvent::ManualLock { user_id } => (*user_id, LockState::Locked),
262            DeviceEvent::ManualUnlock { user_id, user_key } => (
263                *user_id,
264                LockState::Unlocked {
265                    user_key: user_key.to_owned(),
266                },
267            ),
268        };
269
270        tracing::debug!(
271            %user_id,
272            lock_state = lock_state.describe(),
273            "Shared unlock device event reported by this client"
274        );
275
276        self.record_local_state(user_id, lock_state);
277        self.sync_user(user_id).await;
278        Ok(())
279    }
280
281    /// Applies a peer's state if it is newer than what this device has recorded.
282    async fn apply_remote_state(
283        &self,
284        user_id: UserId,
285        remote: TimestampedLockState,
286    ) -> Result<(), ()> {
287        if !remote.supersedes(self.recorded_state(user_id).as_ref()) {
288            return Ok(());
289        }
290
291        // Compared against what this peer *advertises*, not against what it has recorded, so the
292        // two agree: an unobserved user is advertised as locked, and must therefore be
293        // treated as locked here too. Comparing against the raw record instead would make
294        // an incoming `Locked` "differ" from an unobserved user and re-lock an
295        // already-locked device on every restart — which a client that restarts on lock
296        // turns into an endless restart loop.
297        let differs = remote.lock_state != self.advertised_state(user_id).lock_state;
298        if differs {
299            tracing::debug!(
300                %user_id,
301                lock_state = remote.lock_state.describe(),
302                changed_at = remote.changed_at,
303                "Applying a peer's lock state through the driver"
304            );
305
306            match &remote.lock_state {
307                LockState::Locked => self
308                    .0
309                    .driver
310                    .lock_user(user_id)
311                    .await
312                    .inspect_err(|_| warn!(%user_id, "Failed to lock user"))?,
313                LockState::Unlocked { user_key } => self
314                    .0
315                    .driver
316                    .unlock_user(user_id, user_key.to_owned())
317                    .await
318                    .inspect_err(|_| warn!(%user_id, "Failed to unlock user"))?,
319            }
320        }
321
322        self.record_remote_state(user_id, remote);
323        Ok(())
324    }
325
326    /// Sends this peer's state for every logged-in user to every peer it syncs with.
327    async fn sync_all_users(&self) {
328        for user_id in self.0.driver.list_users().await {
329            self.sync_user(user_id).await;
330        }
331    }
332
333    async fn sync_user(&self, user_id: UserId) {
334        for target in self.sync_targets().await {
335            self.sync_user_to(user_id, &target).await;
336        }
337    }
338
339    /// Sends this peer's state for every logged-in user to one specific peer.
340    async fn sync_all_users_to(&self, target: &SyncTarget) {
341        for user_id in self.0.driver.list_users().await {
342            self.sync_user_to(user_id, target).await;
343        }
344    }
345
346    async fn sync_user_to(&self, user_id: UserId, target: &SyncTarget) {
347        if !self.may_sync_user_to(user_id, target).await {
348            return;
349        }
350
351        let message = SharedUnlockSync {
352            user_id,
353            state: self.advertised_state(user_id),
354        };
355        self.send_message(message, target.endpoint.clone()).await;
356    }
357
358    /// Whether a user's state may be sent to a peer.
359    ///
360    /// The peer must be among the user's configured destinations.
361    ///
362    /// A web peer is additionally entitled only to the users whose vault URL is the
363    /// origin it was validated
364    /// against — the same rule [`SharedUnlockPeer::receive_message`] applies to incoming syncs,
365    /// applied outbound so a page served by one vault is never handed another vault's key material.
366    /// A web peer with no recorded origin fails closed.
367    async fn may_sync_user_to(&self, user_id: UserId, target: &SyncTarget) -> bool {
368        if !self.is_destination(user_id, &target.endpoint) {
369            return false;
370        }
371
372        if !matches!(target.endpoint, Endpoint::Web { .. }) {
373            return true;
374        }
375
376        let Some(origin) = target.origin.as_deref() else {
377            warn!(
378                ?target,
379                "Web peer has no validated origin, not syncing to it"
380            );
381            return false;
382        };
383
384        match self.0.driver.get_vault_url(user_id).await {
385            Some(user_vault_url) if user_vault_url == origin => true,
386            Some(user_vault_url) => {
387                warn!(%origin, %user_vault_url, %user_id, "Web peer's origin does not match the user's vault URL, not syncing that user to it");
388                false
389            }
390            None => {
391                warn!(%origin, %user_id, "No vault URL found for user, not syncing that user to a web peer");
392                false
393            }
394        }
395    }
396
397    /// This peer's leader, if it has one, plus every peer that syncs to it.
398    async fn sync_targets(&self) -> Vec<SyncTarget> {
399        let mut targets = self.0.active_peers.targets();
400        if let Some(leader) = self.0.driver.discover_leader().await
401            && !targets.iter().any(|target| target.endpoint == leader)
402        {
403            // A leader is discovered rather than validated, so it carries no origin. In practice it
404            // is never a web endpoint; if it ever were, `may_sync_user_to` fails it closed.
405            targets.push(SyncTarget::without_origin(leader));
406        }
407        targets
408    }
409
410    async fn send_message(&self, message: SharedUnlockSync, recipient: Endpoint) {
411        tracing::debug!(
412            user_id = %message.user_id,
413            lock_state = message.state.lock_state.describe(),
414            changed_at = message.state.changed_at,
415            ?recipient,
416            "Sending a shared unlock sync"
417        );
418
419        if let Err(error) = self
420            .0
421            .ipc_client
422            .send_typed(message, recipient.clone())
423            .await
424        {
425            match error {
426                RequestError::Unreachable => {
427                    // Expected whenever a peer is not running — a device with no desktop app syncs
428                    // into the void on every tick — so this stays at debug to avoid a steady stream
429                    // of warnings for a normal configuration.
430                    tracing::debug!(
431                        ?recipient,
432                        "Shared unlock peer unreachable; sync not delivered"
433                    );
434                }
435                RequestError::Timeout(_) => {
436                    tracing::warn!(?error, "Timeout sending shared unlock sync");
437                }
438                _ => {
439                    tracing::error!(?error, "Failed to send shared unlock IPC message");
440                }
441            }
442        }
443    }
444
445    // --- Lock state -----------------------------------------------------------------------------
446
447    /// The date this peer has recorded for a user, for tests that assert two peers converged on one
448    /// date and not merely on one state. Compiled only for tests and under `test-support`.
449    #[cfg(any(test, feature = "test-support"))]
450    pub fn recorded_changed_at(&self, user_id: UserId) -> Option<u64> {
451        self.recorded_state(user_id).map(|state| state.changed_at)
452    }
453
454    fn recorded_state(&self, user_id: UserId) -> Option<TimestampedLockState> {
455        self.0
456            .states
457            .lock()
458            .unwrap_or_else(|poisoned| poisoned.into_inner())
459            .get(&user_id)
460            .cloned()
461    }
462
463    /// The state to advertise for a user: the recorded one, or a presence marker.
464    fn advertised_state(&self, user_id: UserId) -> TimestampedLockState {
465        self.recorded_state(user_id).unwrap_or_default()
466    }
467
468    /// Records a state reported by a peer, if it still supersedes what is recorded.
469    fn record_remote_state(&self, user_id: UserId, state: TimestampedLockState) {
470        let mut states = self
471            .0
472            .states
473            .lock()
474            .unwrap_or_else(|poisoned| poisoned.into_inner());
475
476        if state.supersedes(states.get(&user_id)) {
477            states.insert(user_id, state);
478        }
479    }
480
481    /// Records a change made on this device.
482    fn record_local_state(&self, user_id: UserId, lock_state: LockState) {
483        let mut states = self
484            .0
485            .states
486            .lock()
487            .unwrap_or_else(|poisoned| poisoned.into_inner());
488
489        let changed_at = states
490            .get(&user_id)
491            .map_or(0, |recorded| recorded.changed_at.saturating_add(1))
492            .max(now_millis());
493
494        states.insert(
495            user_id,
496            TimestampedLockState {
497                lock_state,
498                changed_at,
499            },
500        );
501    }
502}
503
504#[cfg(not(target_arch = "wasm32"))]
505fn spawn(future: impl std::future::Future<Output = ()> + Send + 'static) {
506    tokio::spawn(future);
507}
508
509#[cfg(target_arch = "wasm32")]
510fn spawn(future: impl std::future::Future<Output = ()> + 'static) {
511    wasm_bindgen_futures::spawn_local(future);
512}