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, spawn, 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
250        // Read before applying, which consumes the state.
251        let reported = state.lock_state.peer_state();
252        self.apply_remote_state(user_id, state).await?;
253        self.0.driver.on_peer_state(user_id, reported).await;
254
255        if first_contact {
256            self.sync_all_users_to(&target).await;
257        }
258
259        Ok(())
260    }
261
262    /// Records a lock state change made on this device and syncs it to every peer immediately.
263    pub async fn handle_device_event(&self, event: DeviceEvent) -> Result<(), ()> {
264        let (user_id, lock_state) = match &event {
265            DeviceEvent::ManualLock { user_id } => (*user_id, LockState::Locked),
266            DeviceEvent::ManualUnlock { user_id, user_key } => (
267                *user_id,
268                LockState::Unlocked {
269                    user_key: user_key.to_owned(),
270                },
271            ),
272        };
273
274        tracing::debug!(
275            %user_id,
276            lock_state = lock_state.describe(),
277            "Shared unlock device event reported by this client"
278        );
279
280        let changed_at = self.record_local_state(user_id, lock_state);
281
282        // Persisted, so the lock survives a reload that happens before it reached any peer.
283        if let DeviceEvent::ManualLock { .. } = event {
284            self.0
285                .driver
286                .set_last_manual_lock(user_id, changed_at)
287                .await;
288        }
289
290        self.sync_user(user_id).await;
291        Ok(())
292    }
293
294    /// Applies a peer's state if it is newer than what this device has recorded.
295    async fn apply_remote_state(
296        &self,
297        user_id: UserId,
298        remote: TimestampedLockState,
299    ) -> Result<(), ()> {
300        if !remote.supersedes(self.restored_state(user_id).await.as_ref()) {
301            return Ok(());
302        }
303
304        // Persisted before the driver is called, because a client may reload inside `lock_user`
305        // and lose anything recorded after it. E.g. the desktop locks at T2, the browser applies
306        // it and reloads: the browser must come back as "locked at T2", not adopt the web vault's
307        // older unlock at T1.
308        if let LockState::Locked = remote.lock_state {
309            self.0
310                .driver
311                .set_last_manual_lock(user_id, remote.changed_at)
312                .await;
313        }
314
315        // Compared against what this peer *advertises*, not against what it has recorded, so the
316        // two agree: an unobserved user is advertised as locked, and must therefore be
317        // treated as locked here too. Comparing against the raw record instead would make
318        // an incoming `Locked` "differ" from an unobserved user and re-lock an
319        // already-locked device on every restart — which a client that restarts on lock
320        // turns into an endless restart loop.
321        let differs = remote.lock_state != self.advertised_state(user_id).await.lock_state;
322        if differs {
323            tracing::debug!(
324                %user_id,
325                lock_state = remote.lock_state.describe(),
326                changed_at = remote.changed_at,
327                "Applying a peer's lock state through the driver"
328            );
329
330            self.relay_user_state(user_id, &remote).await;
331
332            match &remote.lock_state {
333                LockState::Locked => self
334                    .0
335                    .driver
336                    .lock_user(user_id)
337                    .await
338                    .inspect_err(|_| warn!(%user_id, "Failed to lock user"))?,
339                LockState::Unlocked { user_key } => self
340                    .0
341                    .driver
342                    .unlock_user(user_id, user_key.to_owned())
343                    .await
344                    .inspect_err(|_| warn!(%user_id, "Failed to unlock user"))?,
345            }
346        }
347
348        self.record_remote_state(user_id, remote);
349        Ok(())
350    }
351
352    /// Sends this peer's state for every logged-in user to every peer it syncs with.
353    async fn sync_all_users(&self) {
354        for user_id in self.0.driver.list_users().await {
355            self.sync_user(user_id).await;
356        }
357    }
358
359    async fn sync_user(&self, user_id: UserId) {
360        for target in self.sync_targets().await {
361            self.sync_user_to(user_id, &target).await;
362        }
363    }
364
365    /// Sends a state to every peer this peer syncs with, without recording it. Used to pass a
366    /// peer's lock on before this device has applied it.
367    async fn relay_user_state(&self, user_id: UserId, state: &TimestampedLockState) {
368        for target in self.sync_targets().await {
369            if !self.may_sync_user_to(user_id, &target).await {
370                continue;
371            }
372
373            let message = SharedUnlockSync {
374                user_id,
375                state: state.clone(),
376            };
377            self.send_message(message, target.endpoint.clone()).await;
378        }
379    }
380
381    /// Sends this peer's state for every logged-in user to one specific peer.
382    async fn sync_all_users_to(&self, target: &SyncTarget) {
383        for user_id in self.0.driver.list_users().await {
384            self.sync_user_to(user_id, target).await;
385        }
386    }
387
388    async fn sync_user_to(&self, user_id: UserId, target: &SyncTarget) {
389        if !self.may_sync_user_to(user_id, target).await {
390            return;
391        }
392
393        let message = SharedUnlockSync {
394            user_id,
395            state: self.advertised_state(user_id).await,
396        };
397        self.send_message(message, target.endpoint.clone()).await;
398    }
399
400    /// Whether a user's state may be sent to a peer.
401    ///
402    /// The peer must be among the user's configured destinations.
403    ///
404    /// A web peer is additionally entitled only to the users whose vault URL is the
405    /// origin it was validated
406    /// against — the same rule [`SharedUnlockPeer::receive_message`] applies to incoming syncs,
407    /// applied outbound so a page served by one vault is never handed another vault's key material.
408    /// A web peer with no recorded origin fails closed.
409    async fn may_sync_user_to(&self, user_id: UserId, target: &SyncTarget) -> bool {
410        if !self.is_destination(user_id, &target.endpoint) {
411            return false;
412        }
413
414        if !matches!(target.endpoint, Endpoint::Web { .. }) {
415            return true;
416        }
417
418        let Some(origin) = target.origin.as_deref() else {
419            warn!(
420                ?target,
421                "Web peer has no validated origin, not syncing to it"
422            );
423            return false;
424        };
425
426        match self.0.driver.get_vault_url(user_id).await {
427            Some(user_vault_url) if user_vault_url == origin => true,
428            Some(user_vault_url) => {
429                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");
430                false
431            }
432            None => {
433                warn!(%origin, %user_id, "No vault URL found for user, not syncing that user to a web peer");
434                false
435            }
436        }
437    }
438
439    /// This peer's leader, if it has one, plus every peer that syncs to it.
440    async fn sync_targets(&self) -> Vec<SyncTarget> {
441        let mut targets = self.0.active_peers.targets();
442        if let Some(leader) = self.0.driver.discover_leader().await
443            && !targets.iter().any(|target| target.endpoint == leader)
444        {
445            // A leader is discovered rather than validated, so it carries no origin. In practice it
446            // is never a web endpoint; if it ever were, `may_sync_user_to` fails it closed.
447            targets.push(SyncTarget::without_origin(leader));
448        }
449        targets
450    }
451
452    async fn send_message(&self, message: SharedUnlockSync, recipient: Endpoint) {
453        tracing::debug!(
454            user_id = %message.user_id,
455            lock_state = message.state.lock_state.describe(),
456            changed_at = message.state.changed_at,
457            ?recipient,
458            "Sending a shared unlock sync"
459        );
460
461        if let Err(error) = self
462            .0
463            .ipc_client
464            .send_typed(message, recipient.clone())
465            .await
466        {
467            match error {
468                RequestError::Unreachable => {
469                    // Expected whenever a peer is not running — a device with no desktop app syncs
470                    // into the void on every tick — so this stays at debug to avoid a steady stream
471                    // of warnings for a normal configuration.
472                    tracing::debug!(
473                        ?recipient,
474                        "Shared unlock peer unreachable; sync not delivered"
475                    );
476                }
477                RequestError::Timeout(_) => {
478                    tracing::debug!(?error, "Timeout sending shared unlock sync");
479                }
480                _ => {
481                    tracing::debug!(?error, "Failed to send shared unlock IPC message");
482                }
483            }
484        }
485    }
486
487    // --- Lock state -----------------------------------------------------------------------------
488
489    /// The date this peer has recorded for a user, for tests that assert two peers converged on one
490    /// date and not merely on one state. Compiled only for tests and under `test-support`.
491    #[cfg(any(test, feature = "test-support"))]
492    pub fn recorded_changed_at(&self, user_id: UserId) -> Option<u64> {
493        self.recorded_state(user_id).map(|state| state.changed_at)
494    }
495
496    fn recorded_state(&self, user_id: UserId) -> Option<TimestampedLockState> {
497        self.0
498            .states
499            .lock()
500            .unwrap_or_else(|poisoned| poisoned.into_inner())
501            .get(&user_id)
502            .cloned()
503    }
504
505    /// The recorded state, or — when nothing is recorded, as after a process reload — the
506    /// persisted last manual lock, which is then recorded.
507    ///
508    /// Without this, a lock made just before a reload is forgotten, and the reloaded peer adopts
509    /// the older unlock its peers still hold. E.g. unlocked at T, locked at T+1, reloaded: the
510    /// peer must come back as "locked at T+1", not "locked at 0".
511    async fn restored_state(&self, user_id: UserId) -> Option<TimestampedLockState> {
512        if let Some(recorded) = self.recorded_state(user_id) {
513            return Some(recorded);
514        }
515
516        let changed_at = self.0.driver.get_last_manual_lock(user_id).await?;
517        let restored = TimestampedLockState {
518            lock_state: LockState::Locked,
519            changed_at,
520        };
521
522        // A state recorded while the driver was awaited is at least as fresh; keep it.
523        let mut states = self
524            .0
525            .states
526            .lock()
527            .unwrap_or_else(|poisoned| poisoned.into_inner());
528        Some(states.entry(user_id).or_insert(restored).clone())
529    }
530
531    /// The state to advertise for a user: 1. the active state, 2. the last lock persisted to disk,
532    /// or 3. a default null value.
533    async fn advertised_state(&self, user_id: UserId) -> TimestampedLockState {
534        self.restored_state(user_id)
535            .await
536            .unwrap_or(TimestampedLockState {
537                lock_state: LockState::Locked,
538                changed_at: 0,
539            })
540    }
541
542    /// Records a state reported by a peer, if it still supersedes what is recorded.
543    fn record_remote_state(&self, user_id: UserId, state: TimestampedLockState) {
544        let mut states = self
545            .0
546            .states
547            .lock()
548            .unwrap_or_else(|poisoned| poisoned.into_inner());
549
550        if state.supersedes(states.get(&user_id)) {
551            states.insert(user_id, state);
552        }
553    }
554
555    /// Records a change made on this device, returning the date it was recorded at.
556    fn record_local_state(&self, user_id: UserId, lock_state: LockState) -> u64 {
557        let mut states = self
558            .0
559            .states
560            .lock()
561            .unwrap_or_else(|poisoned| poisoned.into_inner());
562
563        let changed_at = states
564            .get(&user_id)
565            .map_or(0, |recorded| recorded.changed_at.saturating_add(1))
566            .max(now_millis());
567
568        states.insert(
569            user_id,
570            TimestampedLockState {
571                lock_state,
572                changed_at,
573            },
574        );
575        changed_at
576    }
577}