Skip to main content

bitwarden_vault/cipher/
change_login_password.rs

1use bitwarden_core::key_management::KeySlotIds;
2use bitwarden_crypto::{CryptoError, KeyStore};
3use bitwarden_error::bitwarden_error;
4use thiserror::Error;
5
6use super::{
7    cipher::{Cipher, CipherType, CipherView, EncryptMode, StrictDecrypt},
8    cipher_client::should_use_blob_encryption,
9};
10
11/// Error returned by [`Cipher::change_login_password`].
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum ChangeLoginPasswordError {
15    /// The cipher is partial, so re-encrypting it would drop its password history.
16    #[error("Cannot change the password of a partial cipher")]
17    PartialCipher,
18    /// The cipher is not a login.
19    #[error("Cannot change the login password of a cipher that is not a login")]
20    NotLogin,
21    /// Strict decryption or re-encryption failed.
22    #[error(transparent)]
23    Crypto(#[from] CryptoError),
24}
25
26impl Cipher {
27    /// Decrypts this cipher strictly, sets a new login password, records the old one in the
28    /// password history, and re-encrypts it.
29    pub fn change_login_password(
30        self,
31        key_store: &KeyStore<KeySlotIds>,
32        new_password: &str,
33    ) -> Result<Cipher, ChangeLoginPasswordError> {
34        // A partial original has no password history to chain; see `edit_cipher`.
35        if self.partial_data.is_some() {
36            return Err(ChangeLoginPasswordError::PartialCipher);
37        }
38        if self.r#type != CipherType::Login {
39            return Err(ChangeLoginPasswordError::NotLogin);
40        }
41
42        // Strict: an undecryptable field is an error, as re-encrypting would wipe it.
43        let mut view: CipherView = key_store.decrypt(&StrictDecrypt(self))?;
44        if view.login.is_none() {
45            return Err(ChangeLoginPasswordError::NotLogin);
46        }
47
48        let original = view.clone();
49        if let Some(login) = view.login.as_mut() {
50            login.password = Some(new_password.to_owned());
51        }
52        view.update_password_history(&original);
53
54        // The security state picks the format, as in the edit flows. Organization ciphers stay
55        // field-level until the helper supports them (PM-32430), then switch here automatically.
56        let use_blob = should_use_blob_encryption(&key_store.context(), view.organization_id);
57        let mode = if use_blob {
58            EncryptMode::Blob(view)
59        } else {
60            EncryptMode::Legacy(view)
61        };
62        Ok(key_store.encrypt(mode)?)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use bitwarden_core::key_management::{
69        BLOB_SECURITY_VERSION, SymmetricKeySlotId, create_test_crypto_with_user_key,
70    };
71    use bitwarden_crypto::{Decryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm};
72
73    use super::*;
74    use crate::{
75        LoginUriView, LoginView, PasswordHistoryView,
76        cipher::{CipherRepromptType, blob::try_parse_blob},
77    };
78
79    fn key_store() -> KeyStore<KeySlotIds> {
80        create_test_crypto_with_user_key(SymmetricCryptoKey::make(
81            SymmetricKeyAlgorithm::Aes256CbcHmac,
82        ))
83    }
84
85    fn login_view() -> CipherView {
86        CipherView {
87            partial: false,
88            r#type: CipherType::Login,
89            login: Some(LoginView {
90                username: Some("user".to_string()),
91                password: Some("old-password".to_string()),
92                password_revision_date: None,
93                uris: Some(vec![LoginUriView {
94                    uri: Some("https://example.com".to_string()),
95                    r#match: None,
96                    uri_checksum: None,
97                }]),
98                totp: None,
99                autofill_on_page_load: None,
100                fido2_credentials: None,
101            }),
102            id: Some("fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap()),
103            organization_id: None,
104            folder_id: None,
105            collection_ids: vec![],
106            key: None,
107            name: "My login".to_string(),
108            notes: None,
109            identity: None,
110            card: None,
111            secure_note: None,
112            ssh_key: None,
113            bank_account: None,
114            drivers_license: None,
115            passport: None,
116            favorite: false,
117            reprompt: CipherRepromptType::None,
118            organization_use_totp: true,
119            edit: true,
120            permissions: None,
121            view_password: true,
122            local_data: None,
123            attachments: None,
124            attachment_decryption_failures: None,
125            fields: None,
126            password_history: None,
127            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
128            deleted_date: None,
129            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
130            archived_date: None,
131        }
132    }
133
134    fn encrypt(key_store: &KeyStore<KeySlotIds>, view: CipherView) -> Cipher {
135        key_store.encrypt(EncryptMode::Legacy(view)).unwrap()
136    }
137
138    #[test]
139    fn changes_password_and_keeps_other_fields() {
140        let key_store = key_store();
141        let cipher = encrypt(&key_store, login_view());
142
143        let changed = cipher
144            .change_login_password(&key_store, "new-password")
145            .unwrap();
146
147        let view: CipherView = key_store.decrypt(&changed).unwrap();
148        let login = view.login.unwrap();
149        assert_eq!(login.password.as_deref(), Some("new-password"));
150        assert_eq!(login.username.as_deref(), Some("user"));
151        assert_eq!(
152            login.uris.unwrap()[0].uri.as_deref(),
153            Some("https://example.com")
154        );
155        assert_eq!(view.name, "My login");
156    }
157
158    #[test]
159    fn records_old_password_in_history() {
160        let key_store = key_store();
161        let mut view = login_view();
162        view.password_history = Some(vec![PasswordHistoryView {
163            password: "older-password".to_string(),
164            last_used_date: "2024-01-01T00:00:00Z".parse().unwrap(),
165        }]);
166        let cipher = encrypt(&key_store, view);
167
168        let changed = cipher
169            .change_login_password(&key_store, "new-password")
170            .unwrap();
171
172        let view: CipherView = key_store.decrypt(&changed).unwrap();
173        let history = view.password_history.unwrap();
174        assert_eq!(history.len(), 2);
175        assert_eq!(history[0].password, "old-password");
176        assert_eq!(history[1].password, "older-password");
177        assert!(view.login.unwrap().password_revision_date.is_some());
178    }
179
180    #[test]
181    fn keeps_legacy_format_below_blob_version() {
182        let key_store = key_store();
183        let cipher = encrypt(&key_store, login_view());
184
185        let changed = cipher
186            .change_login_password(&key_store, "new-password")
187            .unwrap();
188
189        assert!(try_parse_blob(&changed).is_none());
190    }
191
192    #[test]
193    fn uses_blob_format_at_blob_version() {
194        let key_store = key_store();
195        key_store.set_security_state_version(BLOB_SECURITY_VERSION);
196        let cipher = key_store.encrypt(EncryptMode::Blob(login_view())).unwrap();
197
198        let changed = cipher
199            .change_login_password(&key_store, "new-password")
200            .unwrap();
201
202        assert!(try_parse_blob(&changed).is_some());
203        let view: CipherView = key_store.decrypt(&changed).unwrap();
204        assert_eq!(
205            view.login.unwrap().password.as_deref(),
206            Some("new-password")
207        );
208        assert_eq!(view.password_history.unwrap()[0].password, "old-password");
209    }
210
211    #[test]
212    fn keeps_the_item_key() {
213        let key_store = key_store();
214        let cipher = encrypt(&key_store, login_view());
215        let original_key = cipher.key.clone().expect("item key is generated");
216
217        let changed = cipher
218            .change_login_password(&key_store, "new-password")
219            .unwrap();
220
221        // The result's fields decrypt under the original item key.
222        let mut ctx = key_store.context();
223        let item_key = ctx
224            .unwrap_symmetric_key(SymmetricKeySlotId::User, &original_key)
225            .unwrap();
226        let name: String = changed.name.unwrap().decrypt(&mut ctx, item_key).unwrap();
227        assert_eq!(name, "My login");
228    }
229
230    #[test]
231    fn refuses_non_login_cipher() {
232        let key_store = key_store();
233        let mut view = login_view();
234        view.r#type = CipherType::SecureNote;
235        view.login = None;
236        let cipher = encrypt(&key_store, view);
237
238        let result = cipher.change_login_password(&key_store, "new-password");
239
240        assert!(matches!(result, Err(ChangeLoginPasswordError::NotLogin)));
241    }
242
243    #[test]
244    fn refuses_partial_cipher() {
245        let key_store = key_store();
246        let mut cipher = encrypt(&key_store, login_view());
247        cipher.partial_data = Some("{}".to_string());
248
249        let result = cipher.change_login_password(&key_store, "new-password");
250
251        assert!(matches!(
252            result,
253            Err(ChangeLoginPasswordError::PartialCipher)
254        ));
255    }
256
257    #[test]
258    fn refuses_cipher_with_undecryptable_field() {
259        let key_store = key_store();
260        let other_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
261            SymmetricKeyAlgorithm::Aes256CbcHmac,
262        ));
263        let mut cipher = encrypt(&key_store, login_view());
264        // Password encrypted under an unrelated key, so it cannot decrypt here.
265        let foreign = encrypt(&other_store, login_view());
266        cipher.login.as_mut().unwrap().password = foreign.login.unwrap().password;
267
268        let result = cipher.change_login_password(&key_store, "new-password");
269
270        assert!(matches!(result, Err(ChangeLoginPasswordError::Crypto(_))));
271    }
272}