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