bitwarden_shared_unlock/lib.rs
1//! # Shared Unlock Protocol
2//!
3//! Synchronizes vault lock state across multiple Bitwarden clients (web, browser extension,
4//! desktop) running in the same session. When a user unlocks their vault on one client, the
5//! unlock propagates to all connected clients.
6//!
7//! ## Peer Model
8//!
9//! Every client runs exactly one [`SharedUnlockPeer`]. Each peer knows the one peer above it in the
10//! device hierarchy — its *leader* — and syncs to it, while also serving whichever peers sync to
11//! it:
12//!
13//! ```text
14//! Web Client ──syncs to──▶ Browser Extension ──syncs to──▶ Desktop App
15//! CLI Client ──syncs to──▶ Desktop App
16//! ```
17//!
18//! The hierarchy decides who talks to whom, not who is in charge. There is no authoritative
19//! participant: reconciliation is symmetric and the freshest state wins. A peer in the middle of
20//! the chain relays simply by applying what it hears and advertising what it holds — the browser
21//! extension needs no special handling for leading the web vault while following the desktop app.
22//!
23//! The desktop app is the only client with no leader; it exclusively serves.
24//!
25//! ## Messages
26//!
27//! There is one message, [`SharedUnlockSync`], carrying a user id and that device's
28//! [`TimestampedLockState`]. It is sent in both directions.
29//!
30//! A peer sends one sync per logged-in user, to its leader and to every active peer:
31//!
32//! - immediately on [`SharedUnlockPeer::start`], so it does not wait an interval to be discovered,
33//! - on every [`DeviceEvent`], so a manual lock or unlock propagates without delay, and
34//! - every [`SYNC_INTERVAL`].
35//! - as a reply to first sync connection
36//!
37//! ```text
38//! Peer Peer above (leader)
39//! │ │
40//! │──Sync(user, state@date)─────────────────▶│ on start, on device event, every interval
41//! │ │ · applies the state if the date is newer
42//! │ │ · registers the sender as an active peer
43//! │ │
44//! │◀─Sync(user, state@date)──────────────────│ once on first contact, then on device
45//! │ · applies the state if the date is newer │ events and every interval
46//! │ · suppresses its vault timeout │
47//! ```
48//!
49//! ## Reconciliation
50//!
51//! On receiving a sync, a peer:
52//!
53//! 1. Drops it if the source is a web client whose origin does not match the user's vault URL. The
54//! origin that passed this check is kept with the peer, and the same check is applied again on
55//! the way out (see [Origin scoping](#origin-scoping)).
56//! 2. Drops it if the user is not in [`SharedUnlockDriver::list_users`] — that is how a peer knows
57//! it has no account for a user, and it never advertises such a user either.
58//! 3. Drops it if `changed_at` is older than the date this device has recorded. A user this device
59//! has recorded nothing for counts as locked at [`SharedUnlockDriver::get_last_manual_lock`], or
60//! at date `0` if there is none. The persisted date keeps a lock made just before a process
61//! reload from losing to an older unlock its peers still hold.
62//! 4. On an *equal* date, drops it unless it is a `Locked` arriving at an unlocked device. Equal
63//! dates mean two devices acted inside the same millisecond without having seen each other, so
64//! the tie is broken toward `Locked`: both sides then resolve it identically and converge
65//! without another round, and the ambiguous case fails closed rather than resurrecting an
66//! unlock.
67//! 5. Otherwise records the incoming state *and its date*, and calls
68//! [`SharedUnlockDriver::lock_user`] or [`SharedUnlockDriver::unlock_user`] if — and only if —
69//! the state actually differs from what was recorded.
70//!
71//! ## Origin scoping
72//!
73//! A web peer is scoped to one origin, in both directions:
74//!
75//! - an incoming sync from a web source is dropped unless its origin is the vault URL of the user
76//! it carries, and
77//! - an outgoing sync is withheld from a web peer unless the user's vault URL is the origin that
78//! peer was registered with — which covers the introductory reply on first contact as much as the
79//! periodic and device-event syncs.
80//!
81//! ## Keep-alive
82//!
83//! A sync received *from this peer's leader* also calls
84//! [`SharedUnlockDriver::suppress_vault_timeout`] for [`SYNC_INTERVAL`] plus
85//! [`VAULT_TIMEOUT_GRACE_PERIOD`], keeping the vault unlocked as long as the shared session is
86//! active. Syncs from peers below do not suppress anything; they only mark the sender active.
87//!
88//! Peers that have not been heard from in [`PEER_STALE_AFTER`] are pruned and stop being synced to.
89//!
90//! ## Security Definitions
91//!
92//! - Attacker Model:
93//! - Attacker gains user-space access to the device while the vault has been locked (steals the
94//! device)
95//! - Security Goal:
96//! - Attacker cannot gain access to the vault key material
97//!
98//! This security definition is aimed at stolen or seized devices. Forensics should not uncover
99//! (passively) recorded or otherwise left behind key material. The IPC encryption prevents such a
100//! compromise.
101//!
102//! There is no further protection provided against active attackers running in userspace while the
103//! vault is unlocked on any of the clients on the device.
104//!
105//! - Attacker Model:
106//! - Attacker controls a website that is not the web vault
107//! - Security Goal:
108//! - Attacker cannot gain access to the vault key material
109//!
110//! This is met by origin validation, which is enforced on both the receive and the send path — see
111//! [Origin scoping](#origin-scoping). Validating only what arrives would not meet the goal: a peer
112//! that is registered after one validated sync goes on to be sent every user's state.
113
114use bitwarden_core::UserId;
115use bitwarden_crypto::SymmetricCryptoKey;
116use bitwarden_ipc::Endpoint;
117use serde::{Deserialize, Serialize};
118
119mod active_peers;
120mod drivers;
121pub use drivers::*;
122mod message;
123pub use message::*;
124mod peer;
125pub use peer::*;
126mod timing;
127
128/// Wasm support module for shared unlock
129#[cfg(feature = "wasm")]
130pub mod wasm;
131
132/// Interval at which a peer syncs its lock state to its leader and to its active peers.
133pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
134/// Additional grace period added to the vault timeout when suppressing it on a sync from the
135/// leader.
136pub const VAULT_TIMEOUT_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(2);
137/// How long a peer may go without syncing before it is pruned and no longer synced to.
138pub const PEER_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
139
140/// Represents the lock state of a user.
141#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
142pub enum LockState {
143 /// The user is locked (does not have a user-key in memory).
144 Locked,
145 /// The user is unlocked (has a user-key in memory).
146 Unlocked {
147 /// The user-key of the unlocked user
148 user_key: SymmetricCryptoKey,
149 },
150}
151
152/// A peer's lock state for a user, stripped of the key material [`LockState::Unlocked`] carries.
153///
154/// Reported to the driver on every sync this device accepts, so a client can tell "a peer
155/// answered, and it is locked" from "no peer answered at all".
156#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(
158 feature = "wasm",
159 derive(tsify::Tsify),
160 tsify(into_wasm_abi, from_wasm_abi)
161)]
162pub enum PeerLockState {
163 /// The peer reported the user as locked.
164 Locked,
165 /// The peer reported the user as unlocked.
166 Unlocked,
167}
168
169impl LockState {
170 /// The state as reported to a driver, which is never handed key material.
171 pub(crate) fn peer_state(&self) -> PeerLockState {
172 match self {
173 LockState::Locked => PeerLockState::Locked,
174 LockState::Unlocked { .. } => PeerLockState::Unlocked,
175 }
176 }
177
178 /// Names the state without touching the key it may carry, so it is safe to log.
179 pub(crate) fn describe(&self) -> &'static str {
180 match self {
181 LockState::Locked => "locked",
182 LockState::Unlocked { .. } => "unlocked",
183 }
184 }
185}
186
187/// The device (client) has several events that need to be reported to the shared unlock system.
188/// This enum represents the events that need to be reported.
189#[derive(Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
190#[bitwarden_ffi::wasm_record]
191pub enum DeviceEvent {
192 /// The user with the given user id has been locked manually in the UI
193 ManualLock {
194 #[zeroize(skip)]
195 /// User whose vault was manually locked.
196 user_id: UserId,
197 },
198 /// The user with the given user id has been unlocked manually in the UI
199 ManualUnlock {
200 #[zeroize(skip)]
201 /// User whose vault was manually unlocked.
202 user_id: UserId,
203 /// Raw user key bytes used to unlock the vault.
204 #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey"))]
205 user_key: SymmetricCryptoKey,
206 },
207}
208
209/// A kind of client a peer may share unlock state with, independent of the IPC endpoint variants
210/// that address its individual contexts (foreground/background, renderer/main).
211#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
212#[bitwarden_ffi::wasm_record]
213pub enum SharedUnlockClient {
214 /// The browser extension, in any of its contexts.
215 Browser,
216 /// The command-line interface, in any of its invocations.
217 Cli,
218 /// The desktop app, in any of its processes.
219 Desktop,
220 /// A web vault tab.
221 Web,
222}
223
224impl SharedUnlockClient {
225 /// The client an endpoint belongs to.
226 pub(crate) fn of_endpoint(endpoint: &Endpoint) -> Self {
227 match endpoint {
228 Endpoint::Web { .. } => SharedUnlockClient::Web,
229 Endpoint::BrowserForeground { .. } | Endpoint::BrowserBackground { .. } => {
230 SharedUnlockClient::Browser
231 }
232 Endpoint::Cli { .. } => SharedUnlockClient::Cli,
233 Endpoint::DesktopRenderer | Endpoint::DesktopMain => SharedUnlockClient::Desktop,
234 }
235 }
236}