Skip to main content

bitwarden_unlock/
unlock_client.rs

1//! Client for unlocking a rehydrated Bitwarden SDK client.
2
3use bitwarden_core::Client;
4#[cfg(feature = "cli")]
5use bitwarden_core::client::persisted_state::{
6    ACCOUNT_CRYPTO_STATE, OrganizationSharedKey, SESSION_PROTECTED_USER_KEY,
7};
8
9use crate::SessionKey;
10
11/// The unlock factor used to unlock a rehydrated client.
12///
13/// Currently only session-key unlock is supported. Master password, PIN,
14/// biometric, and device-key unlock are explicitly out of scope.
15pub enum UnlockMethod {
16    /// Unlock using a session key previously obtained from
17    /// [`UnlockClient::generate_session_key`].
18    SessionKey(SessionKey),
19}
20
21/// Errors returned by [`UnlockClient::generate_session_key`] and
22/// [`UnlockClient::unlock`].
23///
24/// Detailed causes are emitted via `tracing::error!` rather than carried in the
25/// error type, so callers see a uniform failure shape while operators retain
26/// diagnostic visibility through logs.
27#[derive(Debug, thiserror::Error)]
28pub enum UnlockError {
29    /// An unknown error occurred. See logs for details.
30    #[error("An unknown error occurred while unlocking the client")]
31    Unknown,
32}
33
34/// Client for minting session keys and unlocking the vault with one.
35#[derive(Clone)]
36pub struct UnlockClient {
37    #[cfg_attr(not(feature = "cli"), allow(dead_code))]
38    pub(crate) client: Client,
39}
40
41impl UnlockClient {
42    pub(crate) fn new(client: Client) -> Self {
43        Self { client }
44    }
45
46    /// Mint a new session key and persist the user key wrapped by it.
47    ///
48    /// Requires the client to be unlocked (the user key must be present in the
49    /// key store). The returned [`SessionKey`] should be stored outside the SDK
50    /// by the caller and provided back to [`UnlockClient::unlock`] on the next
51    /// rehydrated client.
52    #[cfg(feature = "cli")]
53    pub async fn generate_session_key(&self) -> Result<SessionKey, UnlockError> {
54        use bitwarden_core::key_management::SymmetricKeySlotId;
55
56        let (envelope, session_key) = {
57            let key_store = self.client.internal.get_key_store();
58            let mut ctx = key_store.context_mut();
59            SessionKey::from_context(SymmetricKeySlotId::User, &mut ctx).map_err(|e| {
60                tracing::error!("Failed to encrypt user key with session key: {e}");
61                UnlockError::Unknown
62            })?
63        };
64
65        self.client
66            .platform()
67            .state()
68            .setting(SESSION_PROTECTED_USER_KEY)
69            .map_err(|e| {
70                tracing::error!("Failed to read session_protected_user_key setting handle: {e}");
71                UnlockError::Unknown
72            })?
73            .update(envelope)
74            .await
75            .map_err(|e| {
76                tracing::error!("Failed to save session_protected_user_key: {e}");
77                UnlockError::Unknown
78            })?;
79
80        Ok(session_key)
81    }
82
83    /// Unlock a rehydrated client using the supplied unlock factor.
84    ///
85    /// Reads [`SESSION_PROTECTED_USER_KEY`] and [`ACCOUNT_CRYPTO_STATE`] from
86    /// the state registry, unwraps the user key, initializes the user's crypto
87    /// state, and restores any persisted organization keys.
88    #[cfg(feature = "cli")]
89    pub async fn unlock(&self, unlock: UnlockMethod) -> Result<(), UnlockError> {
90        let UnlockMethod::SessionKey(session_key) = unlock;
91
92        let state = self.client.platform().state();
93
94        let session_protected_user_key = state
95            .setting(SESSION_PROTECTED_USER_KEY)
96            .map_err(|e| {
97                tracing::error!("Failed to read session_protected_user_key setting handle: {e}");
98                UnlockError::Unknown
99            })?
100            .get()
101            .await
102            .map_err(|e| {
103                tracing::error!("Failed to read session_protected_user_key: {e}");
104                UnlockError::Unknown
105            })?
106            .ok_or_else(|| {
107                tracing::error!("Missing session_protected_user_key in database");
108                UnlockError::Unknown
109            })?;
110
111        let account_crypto_state = state
112            .setting(ACCOUNT_CRYPTO_STATE)
113            .map_err(|e| {
114                tracing::error!("Failed to read account_crypto_state setting handle: {e}");
115                UnlockError::Unknown
116            })?
117            .get()
118            .await
119            .map_err(|e| {
120                tracing::error!("Failed to read account_crypto_state: {e}");
121                UnlockError::Unknown
122            })?
123            .ok_or_else(|| {
124                tracing::error!("Missing account_crypto_state in database");
125                UnlockError::Unknown
126            })?;
127
128        let decrypted_key = {
129            let key_store = self.client.internal.get_key_store();
130            let mut ctx = key_store.context_mut();
131            let decrypted_key_id = session_key
132                .unwrap_to_context(&session_protected_user_key, &mut ctx)
133                .map_err(|e| {
134                    tracing::error!("Failed to unseal user key with session key: {e}");
135                    UnlockError::Unknown
136                })?;
137            #[allow(deprecated)]
138            ctx.dangerous_get_symmetric_key(decrypted_key_id)
139                .map_err(|e| {
140                    tracing::error!("Failed to read decrypted user key from key store: {e}");
141                    UnlockError::Unknown
142                })?
143                .clone()
144        };
145
146        self.client
147            .internal
148            .initialize_user_crypto_decrypted_key(decrypted_key, account_crypto_state, &None)
149            .map_err(|e| {
150                tracing::error!("Failed to initialize user crypto with decrypted key: {e}");
151                UnlockError::Unknown
152            })?;
153
154        let org_keys = state
155            .get::<OrganizationSharedKey>()
156            .map_err(|e| {
157                tracing::error!("Failed to read organization keys repository: {e}");
158                UnlockError::Unknown
159            })?
160            .list()
161            .await
162            .map_err(|e| {
163                tracing::error!("Failed to list organization keys: {e}");
164                UnlockError::Unknown
165            })?;
166
167        self.client
168            .internal
169            .initialize_org_crypto(org_keys.into_iter().map(|k| (k.org_id, k.key)).collect())
170            .map_err(|e| {
171                tracing::error!("Failed to decrypt organization keys: {e}");
172                UnlockError::Unknown
173            })?;
174
175        Ok(())
176    }
177
178    /// Invalidate the persisted session key, locking the vault for future invocations.
179    ///
180    /// Removes [`SESSION_PROTECTED_USER_KEY`] from the database. Distinct from
181    /// `lock()` on long-lived clients (mobile, desktop), which clears keys from memory: the
182    /// CLI process exits between invocations, so locking must delete the persisted session
183    /// key rather than mutate in-memory state.
184    #[cfg(feature = "cli")]
185    pub async fn invalidate_session_key(&self) -> Result<(), bitwarden_state::SettingsError> {
186        self.client
187            .platform()
188            .state()
189            .setting(SESSION_PROTECTED_USER_KEY)?
190            .delete()
191            .await
192    }
193}
194
195/// Extension trait to add the unlock client to the main Bitwarden SDK client.
196pub trait UnlockClientExt {
197    /// Get the unlock client.
198    fn unlock(&self) -> UnlockClient;
199}
200
201impl UnlockClientExt for Client {
202    fn unlock(&self) -> UnlockClient {
203        UnlockClient::new(self.clone())
204    }
205}
206
207#[cfg(all(test, feature = "cli"))]
208mod tests {
209    // Clippy's automatic test-code exemption for `unwrap_used` keys off a bare
210    // `#[cfg(test)]` and doesn't trigger when extra cfg predicates are present.
211    #![allow(clippy::unwrap_used)]
212
213    use std::sync::{Arc, Once};
214
215    use bitwarden_core::{
216        Client, DeviceType, HostPlatformInfo, SaveStateData, UserId,
217        auth::auth_tokens::{NoopTokenHandler, TokenHandler},
218        client::persisted_state::{BASE_URLS, BaseUrls, SESSION_PROTECTED_USER_KEY, USER_ID},
219        key_management::{
220            KeySlotIds, SecurityState, SymmetricKeySlotId,
221            account_cryptographic_state::WrappedAccountCryptographicState,
222        },
223    };
224    use bitwarden_crypto::{
225        KeyStore, PublicKeyEncryptionAlgorithm, SignatureAlgorithm, SymmetricCryptoKey,
226        SymmetricKeyAlgorithm,
227        safe::{SymmetricKeyEnvelope, SymmetricKeyEnvelopeNamespace},
228    };
229    use bitwarden_state::registry::StateRegistry;
230
231    use super::*;
232
233    static INIT: Once = Once::new();
234
235    fn ensure_platform_info() {
236        INIT.call_once(|| {
237            bitwarden_core::init_host_platform_info(HostPlatformInfo {
238                user_agent: "unlock-tests".to_string(),
239                device_type: DeviceType::SDK,
240                device_identifier: None,
241                bitwarden_client_version: None,
242                bitwarden_package_type: None,
243            });
244        });
245    }
246
247    fn test_user_id() -> UserId {
248        "d5b1fde2-a1e3-4c5b-9e0f-1a2b3c4d5e6f".parse().unwrap()
249    }
250
251    fn test_base_urls() -> BaseUrls {
252        BaseUrls {
253            identity_url: "https://identity.example.com".to_string(),
254            api_url: "https://api.example.com".to_string(),
255        }
256    }
257
258    fn is_unlocked(client: &Client) -> bool {
259        client
260            .internal
261            .get_key_store()
262            .context()
263            .has_symmetric_key(SymmetricKeySlotId::User)
264    }
265
266    fn user_key_base64(client: &Client) -> String {
267        let key_store = client.internal.get_key_store();
268        let ctx = key_store.context();
269        #[allow(deprecated)]
270        ctx.dangerous_get_symmetric_key(SymmetricKeySlotId::User)
271            .unwrap()
272            .to_base64()
273            .to_string()
274    }
275
276    /// Mint a fresh `(user_key, account_crypto_state)` pair where the wrapped
277    /// state was sealed with `user_key`.
278    fn make_test_user_crypto() -> (SymmetricCryptoKey, WrappedAccountCryptographicState) {
279        let store: KeyStore<KeySlotIds> = KeyStore::default();
280        let mut ctx = store.context_mut();
281        let user_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::XChaCha20Poly1305);
282        let private_key_id = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
283        let signing_key_id = ctx.make_signing_key(SignatureAlgorithm::Ed25519);
284        let signed_public_key = ctx
285            .make_signed_public_key(private_key_id, signing_key_id)
286            .unwrap();
287        let security_state = SecurityState::new();
288        let signed_security_state = security_state.sign(signing_key_id, &mut ctx).unwrap();
289        let wrapped_private = ctx.wrap_private_key(user_key_id, private_key_id).unwrap();
290        let wrapped_signing = ctx.wrap_signing_key(user_key_id, signing_key_id).unwrap();
291        #[allow(deprecated)]
292        let user_key = ctx
293            .dangerous_get_symmetric_key(user_key_id)
294            .unwrap()
295            .clone();
296        (
297            user_key,
298            WrappedAccountCryptographicState::V2 {
299                private_key: wrapped_private,
300                signed_public_key: Some(signed_public_key),
301                signing_key: wrapped_signing,
302                security_state: signed_security_state,
303            },
304        )
305    }
306
307    /// Wrap `user_key` with a freshly generated session key, returning the
308    /// envelope and the session key.
309    fn seal_with_new_session_key(
310        user_key: &SymmetricCryptoKey,
311    ) -> (SymmetricKeyEnvelope, SessionKey) {
312        let store: KeyStore<KeySlotIds> = KeyStore::default();
313        let mut ctx = store.context_mut();
314        let user_key_id = ctx.add_local_symmetric_key(user_key.clone());
315        let session_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::XChaCha20Poly1305);
316        let envelope = SymmetricKeyEnvelope::seal(
317            user_key_id,
318            session_key_id,
319            SymmetricKeyEnvelopeNamespace::SessionKey,
320            &ctx,
321        )
322        .unwrap();
323        #[allow(deprecated)]
324        let session_key = ctx
325            .dangerous_get_symmetric_key(session_key_id)
326            .unwrap()
327            .clone();
328        (envelope, SessionKey(session_key))
329    }
330
331    async fn populate_registry_for_unlock(
332        envelope: SymmetricKeyEnvelope,
333        crypto_state: WrappedAccountCryptographicState,
334    ) -> StateRegistry {
335        let reg = StateRegistry::new_with_memory_db();
336        reg.setting(SESSION_PROTECTED_USER_KEY)
337            .unwrap()
338            .update(envelope)
339            .await
340            .unwrap();
341        Client::save_to_state(
342            SaveStateData {
343                user_id: test_user_id(),
344                urls: test_base_urls(),
345                crypto_state,
346            },
347            &reg,
348        )
349        .await
350        .unwrap();
351        reg
352    }
353
354    #[tokio::test]
355    async fn generate_session_key_persists_envelope() {
356        ensure_platform_info();
357        let (user_key, crypto_state) = make_test_user_crypto();
358
359        let reg = StateRegistry::new_with_memory_db();
360        Client::save_to_state(
361            SaveStateData {
362                user_id: test_user_id(),
363                urls: test_base_urls(),
364                crypto_state: crypto_state.clone(),
365            },
366            &reg,
367        )
368        .await
369        .unwrap();
370
371        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
372        let client = Client::load_from_state(token_handler, reg).await.unwrap();
373        client
374            .internal
375            .initialize_user_crypto_decrypted_key(user_key, crypto_state, &None)
376            .unwrap();
377
378        let _session_key = client.unlock().generate_session_key().await.unwrap();
379
380        let envelope: Option<SymmetricKeyEnvelope> = client
381            .platform()
382            .state()
383            .setting(SESSION_PROTECTED_USER_KEY)
384            .unwrap()
385            .get()
386            .await
387            .unwrap();
388        assert!(
389            envelope.is_some(),
390            "SESSION_PROTECTED_USER_KEY should be persisted after generate_session_key"
391        );
392    }
393
394    #[tokio::test]
395    async fn unlock_with_session_key_restores_user_key() {
396        ensure_platform_info();
397        let (user_key, crypto_state) = make_test_user_crypto();
398        let expected_user_key = user_key.to_base64().to_string();
399        let (envelope, session_key) = seal_with_new_session_key(&user_key);
400
401        let reg = populate_registry_for_unlock(envelope, crypto_state).await;
402        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
403        let client = Client::load_from_state(token_handler, reg).await.unwrap();
404        assert!(
405            !is_unlocked(&client),
406            "Rehydrated client should start locked"
407        );
408
409        client
410            .unlock()
411            .unlock(UnlockMethod::SessionKey(session_key))
412            .await
413            .unwrap();
414
415        assert!(is_unlocked(&client));
416        assert_eq!(
417            user_key_base64(&client),
418            expected_user_key,
419            "Unlocked user key should match the original"
420        );
421    }
422
423    #[tokio::test]
424    async fn unlock_missing_session_protected_user_key_returns_unknown() {
425        ensure_platform_info();
426        let (user_key, crypto_state) = make_test_user_crypto();
427        let (_, session_key) = seal_with_new_session_key(&user_key);
428
429        let reg = StateRegistry::new_with_memory_db();
430        Client::save_to_state(
431            SaveStateData {
432                user_id: test_user_id(),
433                urls: test_base_urls(),
434                crypto_state,
435            },
436            &reg,
437        )
438        .await
439        .unwrap();
440        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
441        let client = Client::load_from_state(token_handler, reg).await.unwrap();
442
443        let result = client
444            .unlock()
445            .unlock(UnlockMethod::SessionKey(session_key))
446            .await;
447        assert!(matches!(result, Err(UnlockError::Unknown)));
448    }
449
450    #[tokio::test]
451    async fn unlock_missing_account_crypto_state_returns_unknown() {
452        ensure_platform_info();
453        let (user_key, _crypto_state) = make_test_user_crypto();
454        let (envelope, session_key) = seal_with_new_session_key(&user_key);
455
456        let reg = StateRegistry::new_with_memory_db();
457        reg.setting(BASE_URLS)
458            .unwrap()
459            .update(test_base_urls())
460            .await
461            .unwrap();
462        reg.setting(USER_ID)
463            .unwrap()
464            .update(test_user_id())
465            .await
466            .unwrap();
467        reg.setting(SESSION_PROTECTED_USER_KEY)
468            .unwrap()
469            .update(envelope)
470            .await
471            .unwrap();
472        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
473        let client = Client::load_from_state(token_handler, reg).await.unwrap();
474
475        let result = client
476            .unlock()
477            .unlock(UnlockMethod::SessionKey(session_key))
478            .await;
479        assert!(matches!(result, Err(UnlockError::Unknown)));
480    }
481
482    #[tokio::test]
483    async fn unlock_with_wrong_session_key_returns_unknown() {
484        ensure_platform_info();
485        let (user_key, crypto_state) = make_test_user_crypto();
486        let (envelope, _real_session_key) = seal_with_new_session_key(&user_key);
487
488        let reg = populate_registry_for_unlock(envelope, crypto_state).await;
489        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
490        let client = Client::load_from_state(token_handler, reg).await.unwrap();
491
492        let wrong_key = SymmetricCryptoKey::make_xchacha20_poly1305_key();
493        let result = client
494            .unlock()
495            .unlock(UnlockMethod::SessionKey(SessionKey(wrong_key)))
496            .await;
497        assert!(matches!(result, Err(UnlockError::Unknown)));
498    }
499}