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 date `0`.
60//! 4. On an *equal* date, drops it unless it is a `Locked` arriving at an unlocked device. Equal
61//! dates mean two devices acted inside the same millisecond without having seen each other, so
62//! the tie is broken toward `Locked`: both sides then resolve it identically and converge
63//! without another round, and the ambiguous case fails closed rather than resurrecting an
64//! unlock.
65//! 5. Otherwise records the incoming state *and its date*, and calls
66//! [`SharedUnlockDriver::lock_user`] or [`SharedUnlockDriver::unlock_user`] if — and only if —
67//! the state actually differs from what was recorded.
68//!
69//! ## Origin scoping
70//!
71//! A web peer is scoped to one origin, in both directions:
72//!
73//! - an incoming sync from a web source is dropped unless its origin is the vault URL of the user
74//! it carries, and
75//! - an outgoing sync is withheld from a web peer unless the user's vault URL is the origin that
76//! peer was registered with — which covers the introductory reply on first contact as much as the
77//! periodic and device-event syncs.
78//!
79//! ## Keep-alive
80//!
81//! A sync received *from this peer's leader* also calls
82//! [`SharedUnlockDriver::suppress_vault_timeout`] for [`SYNC_INTERVAL`] plus
83//! [`VAULT_TIMEOUT_GRACE_PERIOD`], keeping the vault unlocked as long as the shared session is
84//! active. Syncs from peers below do not suppress anything; they only mark the sender active.
85//!
86//! Peers that have not been heard from in [`PEER_STALE_AFTER`] are pruned and stop being synced to.
87//!
88//! ## Security Definitions
89//!
90//! - Attacker Model:
91//! - Attacker gains user-space access to the device while the vault has been locked (steals the
92//! device)
93//! - Security Goal:
94//! - Attacker cannot gain access to the vault key material
95//!
96//! This security definition is aimed at stolen or seized devices. Forensics should not uncover
97//! (passively) recorded or otherwise left behind key material. The IPC encryption prevents such a
98//! compromise.
99//!
100//! There is no further protection provided against active attackers running in userspace while the
101//! vault is unlocked on any of the clients on the device.
102//!
103//! - Attacker Model:
104//! - Attacker controls a website that is not the web vault
105//! - Security Goal:
106//! - Attacker cannot gain access to the vault key material
107//!
108//! This is met by origin validation, which is enforced on both the receive and the send path — see
109//! [Origin scoping](#origin-scoping). Validating only what arrives would not meet the goal: a peer
110//! that is registered after one validated sync goes on to be sent every user's state.
111
112use bitwarden_core::UserId;
113use bitwarden_crypto::SymmetricCryptoKey;
114use bitwarden_ipc::Endpoint;
115use serde::{Deserialize, Serialize};
116
117mod active_peers;
118mod drivers;
119pub use drivers::*;
120mod message;
121pub use message::*;
122mod peer;
123pub use peer::*;
124mod timing;
125
126/// Wasm support module for shared unlock
127#[cfg(feature = "wasm")]
128pub mod wasm;
129
130/// Interval at which a peer syncs its lock state to its leader and to its active peers.
131pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
132/// Additional grace period added to the vault timeout when suppressing it on a sync from the
133/// leader.
134pub const VAULT_TIMEOUT_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(2);
135/// How long a peer may go without syncing before it is pruned and no longer synced to.
136pub const PEER_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
137
138/// Represents the lock state of a user.
139#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
140pub enum LockState {
141 /// The user is locked (does not have a user-key in memory).
142 Locked,
143 /// The user is unlocked (has a user-key in memory).
144 Unlocked {
145 /// The user-key of the unlocked user
146 user_key: SymmetricCryptoKey,
147 },
148}
149
150impl LockState {
151 /// Names the state without touching the key it may carry, so it is safe to log.
152 pub(crate) fn describe(&self) -> &'static str {
153 match self {
154 LockState::Locked => "locked",
155 LockState::Unlocked { .. } => "unlocked",
156 }
157 }
158}
159
160/// The device (client) has several events that need to be reported to the shared unlock system.
161/// This enum represents the events that need to be reported.
162#[derive(Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
163#[cfg_attr(
164 feature = "wasm",
165 derive(tsify::Tsify),
166 tsify(into_wasm_abi, from_wasm_abi)
167)]
168pub enum DeviceEvent {
169 /// The user with the given user id has been locked manually in the UI
170 ManualLock {
171 #[zeroize(skip)]
172 /// User whose vault was manually locked.
173 user_id: UserId,
174 },
175 /// The user with the given user id has been unlocked manually in the UI
176 ManualUnlock {
177 #[zeroize(skip)]
178 /// User whose vault was manually unlocked.
179 user_id: UserId,
180 /// Raw user key bytes used to unlock the vault.
181 #[cfg_attr(feature = "wasm", tsify(type = "SymmetricKey"))]
182 user_key: SymmetricCryptoKey,
183 },
184}
185
186/// A kind of client a peer may share unlock state with, independent of the IPC endpoint variants
187/// that address its individual contexts (foreground/background, renderer/main).
188#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
189#[cfg_attr(
190 feature = "wasm",
191 derive(tsify::Tsify),
192 tsify(into_wasm_abi, from_wasm_abi)
193)]
194pub enum SharedUnlockClient {
195 /// The browser extension, in any of its contexts.
196 Browser,
197 /// The desktop app, in any of its processes.
198 Desktop,
199 /// A web vault tab.
200 Web,
201}
202
203impl SharedUnlockClient {
204 /// The client an endpoint belongs to.
205 pub(crate) fn of_endpoint(endpoint: &Endpoint) -> Self {
206 match endpoint {
207 Endpoint::Web { .. } => SharedUnlockClient::Web,
208 Endpoint::BrowserForeground { .. } | Endpoint::BrowserBackground { .. } => {
209 SharedUnlockClient::Browser
210 }
211 Endpoint::DesktopRenderer | Endpoint::DesktopMain => SharedUnlockClient::Desktop,
212 }
213 }
214}