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_email() -> String {
252        "[email protected]".to_string()
253    }
254
255    fn test_base_urls() -> BaseUrls {
256        BaseUrls {
257            identity_url: "https://identity.example.com".to_string(),
258            api_url: "https://api.example.com".to_string(),
259        }
260    }
261
262    fn is_unlocked(client: &Client) -> bool {
263        client
264            .internal
265            .get_key_store()
266            .context()
267            .has_symmetric_key(SymmetricKeySlotId::User)
268    }
269
270    fn user_key_base64(client: &Client) -> String {
271        let key_store = client.internal.get_key_store();
272        let ctx = key_store.context();
273        #[allow(deprecated)]
274        ctx.dangerous_get_symmetric_key(SymmetricKeySlotId::User)
275            .unwrap()
276            .to_base64()
277            .to_string()
278    }
279
280    /// Mint a fresh `(user_key, account_crypto_state)` pair where the wrapped
281    /// state was sealed with `user_key`.
282    fn make_test_user_crypto() -> (SymmetricCryptoKey, WrappedAccountCryptographicState) {
283        let store: KeyStore<KeySlotIds> = KeyStore::default();
284        let mut ctx = store.context_mut();
285        let user_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::XChaCha20Poly1305);
286        let private_key_id = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
287        let signing_key_id = ctx.make_signing_key(SignatureAlgorithm::Ed25519);
288        let signed_public_key = ctx
289            .make_signed_public_key(private_key_id, signing_key_id)
290            .unwrap();
291        let security_state = SecurityState::new();
292        let signed_security_state = security_state.sign(signing_key_id, &mut ctx).unwrap();
293        let wrapped_private = ctx.wrap_private_key(user_key_id, private_key_id).unwrap();
294        let wrapped_signing = ctx.wrap_signing_key(user_key_id, signing_key_id).unwrap();
295        #[allow(deprecated)]
296        let user_key = ctx
297            .dangerous_get_symmetric_key(user_key_id)
298            .unwrap()
299            .clone();
300        (
301            user_key,
302            WrappedAccountCryptographicState::V2 {
303                private_key: wrapped_private,
304                signed_public_key: Some(signed_public_key),
305                signing_key: wrapped_signing,
306                security_state: signed_security_state,
307            },
308        )
309    }
310
311    /// Wrap `user_key` with a freshly generated session key, returning the
312    /// envelope and the session key.
313    fn seal_with_new_session_key(
314        user_key: &SymmetricCryptoKey,
315    ) -> (SymmetricKeyEnvelope, SessionKey) {
316        let store: KeyStore<KeySlotIds> = KeyStore::default();
317        let mut ctx = store.context_mut();
318        let user_key_id = ctx.add_local_symmetric_key(user_key.clone());
319        let session_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::XChaCha20Poly1305);
320        let envelope = SymmetricKeyEnvelope::seal(
321            user_key_id,
322            session_key_id,
323            SymmetricKeyEnvelopeNamespace::SessionKey,
324            &ctx,
325        )
326        .unwrap();
327        #[allow(deprecated)]
328        let session_key = ctx
329            .dangerous_get_symmetric_key(session_key_id)
330            .unwrap()
331            .clone();
332        (envelope, SessionKey(session_key))
333    }
334
335    async fn populate_registry_for_unlock(
336        envelope: SymmetricKeyEnvelope,
337        crypto_state: WrappedAccountCryptographicState,
338    ) -> StateRegistry {
339        let reg = StateRegistry::new_with_memory_db();
340        reg.setting(SESSION_PROTECTED_USER_KEY)
341            .unwrap()
342            .update(envelope)
343            .await
344            .unwrap();
345        Client::save_to_state(
346            SaveStateData {
347                user_id: test_user_id(),
348                email: test_email(),
349                urls: test_base_urls(),
350                crypto_state,
351            },
352            &reg,
353        )
354        .await
355        .unwrap();
356        reg
357    }
358
359    #[tokio::test]
360    async fn generate_session_key_persists_envelope() {
361        ensure_platform_info();
362        let (user_key, crypto_state) = make_test_user_crypto();
363
364        let reg = StateRegistry::new_with_memory_db();
365        Client::save_to_state(
366            SaveStateData {
367                user_id: test_user_id(),
368                email: test_email(),
369                urls: test_base_urls(),
370                crypto_state: crypto_state.clone(),
371            },
372            &reg,
373        )
374        .await
375        .unwrap();
376
377        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
378        let client = Client::load_from_state(token_handler, reg).await.unwrap();
379        client
380            .internal
381            .initialize_user_crypto_decrypted_key(user_key, crypto_state, &None)
382            .unwrap();
383
384        let _session_key = client.unlock().generate_session_key().await.unwrap();
385
386        let envelope: Option<SymmetricKeyEnvelope> = client
387            .platform()
388            .state()
389            .setting(SESSION_PROTECTED_USER_KEY)
390            .unwrap()
391            .get()
392            .await
393            .unwrap();
394        assert!(
395            envelope.is_some(),
396            "SESSION_PROTECTED_USER_KEY should be persisted after generate_session_key"
397        );
398    }
399
400    #[tokio::test]
401    async fn unlock_with_session_key_restores_user_key() {
402        ensure_platform_info();
403        let (user_key, crypto_state) = make_test_user_crypto();
404        let expected_user_key = user_key.to_base64().to_string();
405        let (envelope, session_key) = seal_with_new_session_key(&user_key);
406
407        let reg = populate_registry_for_unlock(envelope, crypto_state).await;
408        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
409        let client = Client::load_from_state(token_handler, reg).await.unwrap();
410        assert!(
411            !is_unlocked(&client),
412            "Rehydrated client should start locked"
413        );
414
415        client
416            .unlock()
417            .unlock(UnlockMethod::SessionKey(session_key))
418            .await
419            .unwrap();
420
421        assert!(is_unlocked(&client));
422        assert_eq!(
423            user_key_base64(&client),
424            expected_user_key,
425            "Unlocked user key should match the original"
426        );
427    }
428
429    #[tokio::test]
430    async fn unlock_missing_session_protected_user_key_returns_unknown() {
431        ensure_platform_info();
432        let (user_key, crypto_state) = make_test_user_crypto();
433        let (_, session_key) = seal_with_new_session_key(&user_key);
434
435        let reg = StateRegistry::new_with_memory_db();
436        Client::save_to_state(
437            SaveStateData {
438                user_id: test_user_id(),
439                email: test_email(),
440                urls: test_base_urls(),
441                crypto_state,
442            },
443            &reg,
444        )
445        .await
446        .unwrap();
447        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
448        let client = Client::load_from_state(token_handler, reg).await.unwrap();
449
450        let result = client
451            .unlock()
452            .unlock(UnlockMethod::SessionKey(session_key))
453            .await;
454        assert!(matches!(result, Err(UnlockError::Unknown)));
455    }
456
457    #[tokio::test]
458    async fn unlock_missing_account_crypto_state_returns_unknown() {
459        ensure_platform_info();
460        let (user_key, _crypto_state) = make_test_user_crypto();
461        let (envelope, session_key) = seal_with_new_session_key(&user_key);
462
463        let reg = StateRegistry::new_with_memory_db();
464        reg.setting(BASE_URLS)
465            .unwrap()
466            .update(test_base_urls())
467            .await
468            .unwrap();
469        reg.setting(USER_ID)
470            .unwrap()
471            .update(test_user_id())
472            .await
473            .unwrap();
474        reg.setting(SESSION_PROTECTED_USER_KEY)
475            .unwrap()
476            .update(envelope)
477            .await
478            .unwrap();
479        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
480        let client = Client::load_from_state(token_handler, reg).await.unwrap();
481
482        let result = client
483            .unlock()
484            .unlock(UnlockMethod::SessionKey(session_key))
485            .await;
486        assert!(matches!(result, Err(UnlockError::Unknown)));
487    }
488
489    #[tokio::test]
490    async fn unlock_with_wrong_session_key_returns_unknown() {
491        ensure_platform_info();
492        let (user_key, crypto_state) = make_test_user_crypto();
493        let (envelope, _real_session_key) = seal_with_new_session_key(&user_key);
494
495        let reg = populate_registry_for_unlock(envelope, crypto_state).await;
496        let token_handler: Arc<dyn TokenHandler> = Arc::new(NoopTokenHandler);
497        let client = Client::load_from_state(token_handler, reg).await.unwrap();
498
499        let wrong_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::XChaCha20Poly1305);
500        let result = client
501            .unlock()
502            .unlock(UnlockMethod::SessionKey(SessionKey(wrong_key)))
503            .await;
504        assert!(matches!(result, Err(UnlockError::Unknown)));
505    }
506}