Skip to main content

bitwarden_pam/rotation/
cipher.rs

1//! The vault cipher half of a rotation.
2//!
3//! [`rotate_cipher_password`] vets the cipher the server returned and builds the write that
4//! replaces its password. Decryption, password history and encryption are the vault's
5//! ([`Cipher::change_login_password`]). [`cipher_has_password`] tells whether a cipher already
6//! holds a given password, so an ambiguous write outcome can be settled.
7
8use bitwarden_api_api::models::CipherRequestModel;
9use bitwarden_core::{OrganizationId, key_management::KeySlotIds};
10use bitwarden_crypto::KeyStore;
11use bitwarden_vault::{ChangeLoginPasswordError, Cipher, CipherType, CipherView};
12use thiserror::Error;
13
14/// Errors from the cipher half of a rotation. They never include cipher content.
15#[derive(Debug, Error)]
16pub enum RotationCipherError {
17    /// The cipher belongs to a different organization than the connector's.
18    #[error("cipher does not belong to the connector's organization")]
19    CipherOrganizationMismatch,
20
21    /// The cipher is not a login, or has no login data.
22    #[error("cipher is not a login")]
23    CipherNotLogin,
24
25    /// The server returned a restricted cipher, which must never be rotated.
26    #[error("cipher is restricted")]
27    CipherRestricted,
28
29    /// The cipher has no item key. Rotating it would make the vault generate a new one, and the
30    /// rotation never changes the item key.
31    #[error("cipher has no item key")]
32    CipherWithoutItemKey,
33
34    /// The re-encrypted cipher carries a `data` blob, which the server rejects.
35    #[error("re-encrypted cipher carries a data blob")]
36    CipherDataPresent,
37
38    /// Strict decryption or re-encryption failed.
39    #[error("cryptographic operation failed")]
40    Crypto(#[from] bitwarden_crypto::CryptoError),
41
42    /// JSON serialisation/deserialisation error.
43    #[error("JSON error")]
44    Json(#[from] serde_json::Error),
45}
46
47impl From<ChangeLoginPasswordError> for RotationCipherError {
48    fn from(err: ChangeLoginPasswordError) -> Self {
49        match err {
50            ChangeLoginPasswordError::PartialCipher => Self::CipherRestricted,
51            ChangeLoginPasswordError::NotLogin => Self::CipherNotLogin,
52            ChangeLoginPasswordError::Crypto(e) => Self::Crypto(e),
53        }
54    }
55}
56
57/// Builds the cipher write that rotates `cipher`'s password to `new_password`.
58///
59/// Rejects ciphers from another organization, non-logins, restricted ciphers (re-encrypting would
60/// wipe the withheld fields) and ciphers without an item key. Decryption is strict, so a field that
61/// fails to decrypt is an error rather than wiped. The request sends back the item key as fetched,
62/// since the server requires it unchanged.
63pub fn rotate_cipher_password(
64    key_store: &KeyStore<KeySlotIds>,
65    organization_id: OrganizationId,
66    cipher: Cipher,
67    new_password: &str,
68) -> Result<CipherRequestModel, RotationCipherError> {
69    if cipher.organization_id != Some(organization_id) {
70        return Err(RotationCipherError::CipherOrganizationMismatch);
71    }
72    if cipher.r#type != CipherType::Login {
73        return Err(RotationCipherError::CipherNotLogin);
74    }
75    if cipher.partial_data.is_some() {
76        return Err(RotationCipherError::CipherRestricted);
77    }
78    let original_key = cipher
79        .key
80        .clone()
81        .ok_or(RotationCipherError::CipherWithoutItemKey)?;
82
83    let rotated = cipher.change_login_password(key_store, new_password)?;
84    let mut request = CipherRequestModel::try_from(rotated)?;
85    request.key = Some(original_key.to_string());
86    if request.data.is_some() {
87        return Err(RotationCipherError::CipherDataPresent);
88    }
89    Ok(request)
90}
91
92/// Whether `cipher`'s login password is `password`.
93///
94/// Settles a write whose outcome is unknown: the server may have stored it even though the
95/// connector saw a rejection. A cipher without a login is [`RotationCipherError::CipherNotLogin`].
96pub fn cipher_has_password(
97    key_store: &KeyStore<KeySlotIds>,
98    cipher: &Cipher,
99    password: &str,
100) -> Result<bool, RotationCipherError> {
101    let view: CipherView = key_store.decrypt(cipher)?;
102    let login = view.login.ok_or(RotationCipherError::CipherNotLogin)?;
103    Ok(login.password.as_deref() == Some(password))
104}
105
106/// Fixture builders for the tests below.
107#[cfg(test)]
108mod test_support {
109    use bitwarden_crypto::{Decryptable, EncString, SymmetricCryptoKey};
110
111    use super::*;
112
113    pub(super) fn org_id() -> OrganizationId {
114        "3f1a5c4e-7b2d-4f8a-9c6e-1d2b3a4c5d6e"
115            .parse()
116            .expect("valid uuid")
117    }
118
119    /// The org key every cipher vector in `tests/fixtures/ciphers` is encrypted under (base64 of
120    /// the encoded `SymmetricCryptoKey`).
121    const ORG_KEY_B64: &str =
122        "a4oGKmzuPXlJr6ANfFLy9DHw7lmaCo47mc01fROBKMUklVouVlwvgGsX1Sz9D9JHKbqOku/K25akClXWyp8a4g==";
123
124    /// Org cipher with its own item key, a URI, notes and one password-history entry.
125    pub(super) const KEYED_LOGIN: &str =
126        include_str!("../../tests/fixtures/ciphers/keyed_login.json");
127    /// `KEYED_LOGIN` plus an attachment with its own key.
128    pub(super) const KEYED_LOGIN_WITH_ATTACHMENT: &str =
129        include_str!("../../tests/fixtures/ciphers/keyed_login_with_attachment.json");
130    /// Cipher without an item key: every field is encrypted directly under the org key.
131    pub(super) const KEYLESS_LOGIN: &str =
132        include_str!("../../tests/fixtures/ciphers/keyless_login.json");
133    /// Server-restricted cipher: carries `partialData`.
134    pub(super) const RESTRICTED_LOGIN: &str =
135        include_str!("../../tests/fixtures/ciphers/restricted_login.json");
136
137    /// A vector's JSON, for tests to edit before decrypting it.
138    pub(super) fn vector_json(vector: &str) -> serde_json::Value {
139        serde_json::from_str(vector).expect("vector is JSON")
140    }
141
142    /// `KEYED_LOGIN` as the GET response JSON.
143    pub(super) fn keyed_login_json() -> serde_json::Value {
144        vector_json(KEYED_LOGIN)
145    }
146
147    /// The `attachments` array of `KEYED_LOGIN_WITH_ATTACHMENT`, for grafting onto another vector.
148    pub(super) fn attachments_json() -> serde_json::Value {
149        vector_json(KEYED_LOGIN_WITH_ATTACHMENT)["attachments"].clone()
150    }
151
152    /// A key store holding the vectors' org key in the test org's slot.
153    pub(super) fn org_store() -> KeyStore<KeySlotIds> {
154        let org_key: SymmetricCryptoKey = ORG_KEY_B64.parse().expect("valid org key");
155        let store = KeyStore::default();
156        #[allow(deprecated)]
157        store
158            .context_mut()
159            .set_symmetric_key(
160                bitwarden_core::key_management::SymmetricKeySlotId::Organization(org_id()),
161                org_key,
162            )
163            .expect("set_symmetric_key");
164        store
165    }
166
167    /// Decrypts the string at JSON pointer `pointer` in a cipher write `body` under the item key
168    /// its own `key` carries.
169    pub(super) fn decrypt_body_field(
170        store: &KeyStore<KeySlotIds>,
171        body: &serde_json::Value,
172        pointer: &str,
173    ) -> String {
174        // The body is the real request type, not just JSON that happens to decrypt.
175        serde_json::from_value::<CipherRequestModel>(body.clone())
176            .expect("body is a CipherRequestModel");
177        let mut ctx = store.context_mut();
178        let wrapped: EncString = body["key"]
179            .as_str()
180            .expect("body carries a key")
181            .parse()
182            .expect("key parses");
183        let item_key = ctx
184            .unwrap_symmetric_key(
185                bitwarden_core::key_management::SymmetricKeySlotId::Organization(org_id()),
186                &wrapped,
187            )
188            .expect("item key unwraps under the org key");
189        let enc: EncString = body
190            .pointer(pointer)
191            .and_then(serde_json::Value::as_str)
192            .unwrap_or_else(|| panic!("no string at {pointer}"))
193            .parse()
194            .expect("field parses");
195        enc.decrypt(&mut ctx, item_key).expect("field decrypts")
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use bitwarden_crypto::{KeyEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm};
202
203    use super::{test_support::*, *};
204
205    fn cipher_from(json: serde_json::Value) -> Cipher {
206        let model: bitwarden_api_api::models::CipherMiniResponseModel =
207            serde_json::from_value(json).expect("model");
208        Cipher::try_from(model).expect("cipher")
209    }
210
211    const NEW_PW: &str = "new-pw";
212
213    fn rotate(json: serde_json::Value) -> Result<CipherRequestModel, RotationCipherError> {
214        rotate_cipher_password(&org_store(), org_id(), cipher_from(json), NEW_PW)
215    }
216
217    #[test]
218    fn rotate_cipher_password_rejects_cipher_without_item_key() {
219        let result = rotate(vector_json(KEYLESS_LOGIN));
220        assert!(matches!(
221            result,
222            Err(RotationCipherError::CipherWithoutItemKey)
223        ));
224    }
225
226    #[test]
227    fn rotate_cipher_password_rejects_other_organization() {
228        let mut json = keyed_login_json();
229        json["organizationId"] = uuid::Uuid::new_v4().to_string().into();
230
231        assert!(matches!(
232            rotate(json),
233            Err(RotationCipherError::CipherOrganizationMismatch)
234        ));
235    }
236
237    #[test]
238    fn rotate_cipher_password_rejects_missing_organization() {
239        let mut json = keyed_login_json();
240        json.as_object_mut().unwrap().remove("organizationId");
241
242        assert!(matches!(
243            rotate(json),
244            Err(RotationCipherError::CipherOrganizationMismatch)
245        ));
246    }
247
248    #[test]
249    fn rotate_cipher_password_rejects_non_login_type() {
250        let mut json = keyed_login_json();
251        json["type"] = 2.into();
252
253        assert!(matches!(
254            rotate(json),
255            Err(RotationCipherError::CipherNotLogin)
256        ));
257    }
258
259    #[test]
260    fn rotate_cipher_password_rejects_keyless_cipher_with_attachments() {
261        // The keyless vector with the attachments of the keyed one grafted on.
262        let mut json = vector_json(KEYLESS_LOGIN);
263        json["attachments"] = attachments_json();
264
265        assert!(matches!(
266            rotate(json),
267            Err(RotationCipherError::CipherWithoutItemKey)
268        ));
269    }
270
271    #[test]
272    fn rotate_cipher_password_accepts_attachments_with_item_key() {
273        let result = rotate(vector_json(KEYED_LOGIN_WITH_ATTACHMENT));
274        assert!(result.is_ok(), "{result:?}");
275    }
276
277    #[test]
278    fn rotate_cipher_password_rejects_restricted_cipher() {
279        assert!(matches!(
280            rotate(vector_json(RESTRICTED_LOGIN)),
281            Err(RotationCipherError::CipherRestricted)
282        ));
283    }
284
285    #[test]
286    fn rotate_cipher_password_rejects_login_without_login_data() {
287        let mut json = keyed_login_json();
288        json.as_object_mut().unwrap().remove("login");
289
290        assert!(rotate(json).is_err());
291    }
292
293    #[test]
294    fn rotate_cipher_password_rejects_undecryptable_field() {
295        let other_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
296        let foreign = "x"
297            .encrypt_with_key(&other_key)
298            .expect("encrypt")
299            .to_string();
300
301        for field in ["/login/password", "/login/username", "/notes", "/name"] {
302            let mut json = keyed_login_json();
303            *json.pointer_mut(field).expect("field present") = foreign.clone().into();
304
305            assert!(rotate(json).is_err(), "{field}: must not decrypt");
306        }
307    }
308
309    fn decrypted_json(store: &KeyStore<KeySlotIds>, request: &CipherRequestModel) -> Vec<String> {
310        let body = serde_json::to_value(request).expect("serialize");
311        [
312            "/login/password",
313            "/login/username",
314            "/login/uris/0/uri",
315            "/name",
316            "/notes",
317            "/passwordHistory/0/password",
318        ]
319        .iter()
320        .map(|p| decrypt_body_field(store, &body, p))
321        .collect()
322    }
323
324    #[test]
325    fn rotate_cipher_password_rewrites_password_and_history() {
326        let store = org_store();
327        {
328            let label = "item key";
329            let json = keyed_login_json();
330            let original_key = json["key"].as_str().expect("key").to_owned();
331            let request = rotate_cipher_password(&store, org_id(), cipher_from(json), "new-pw")
332                .expect("rotate");
333
334            assert!(request.data.is_none(), "{label}");
335            assert_eq!(
336                request.key.as_deref(),
337                Some(original_key.as_str()),
338                "{label}: write carries the original item key"
339            );
340            assert_eq!(
341                request.organization_id,
342                Some(org_id().to_string()),
343                "{label}"
344            );
345            assert_eq!(
346                request.r#type,
347                Some(bitwarden_api_api::models::CipherType::Login),
348                "{label}"
349            );
350            assert_eq!(
351                request.last_known_revision_date.as_deref(),
352                Some("2024-06-01T12:00:00Z"),
353                "{label}"
354            );
355            let body = serde_json::to_value(&request).expect("serialize");
356            assert!(body["login"]["passwordRevisionDate"].is_string(), "{label}");
357
358            assert_eq!(
359                decrypted_json(&store, &request),
360                [
361                    "new-pw",
362                    "svc-account",
363                    "https://db.example.com",
364                    "Prod database",
365                    "rotated by PAM",
366                    "old-password",
367                ],
368                "{label}"
369            );
370            // The older entry follows the new one.
371            assert_eq!(
372                decrypt_body_field(&store, &body, "/passwordHistory/1/password"),
373                "older-password",
374                "{label}"
375            );
376        }
377    }
378
379    #[test]
380    fn rotate_cipher_password_without_old_password_adds_no_history_entry() {
381        let store = org_store();
382        {
383            let label = "item key";
384            let mut json = keyed_login_json();
385            json["login"]["password"] = serde_json::Value::Null;
386            let request = rotate_cipher_password(&store, org_id(), cipher_from(json), "first-pw")
387                .expect("rotate");
388            let body = serde_json::to_value(&request).expect("serialize");
389
390            assert_eq!(
391                decrypt_body_field(&store, &body, "/login/password"),
392                "first-pw",
393                "{label}"
394            );
395            assert!(body["login"]["passwordRevisionDate"].is_string(), "{label}");
396            // Only the pre-existing entry remains.
397            assert_eq!(
398                body["passwordHistory"].as_array().map(Vec::len),
399                Some(1),
400                "{label}"
401            );
402            assert_eq!(
403                decrypt_body_field(&store, &body, "/passwordHistory/0/password"),
404                "older-password",
405                "{label}"
406            );
407        }
408    }
409
410    #[test]
411    fn cipher_has_password_matches_current_password() {
412        let cipher = cipher_from(keyed_login_json());
413        assert!(cipher_has_password(&org_store(), &cipher, "old-password").unwrap());
414    }
415
416    #[test]
417    fn cipher_has_password_rejects_other_password() {
418        let cipher = cipher_from(keyed_login_json());
419        assert!(!cipher_has_password(&org_store(), &cipher, "other-password").unwrap());
420    }
421
422    #[test]
423    fn cipher_has_password_without_login_is_not_login() {
424        let mut json = keyed_login_json();
425        json.as_object_mut().unwrap().remove("login");
426
427        assert!(matches!(
428            cipher_has_password(&org_store(), &cipher_from(json), "old-password"),
429            Err(RotationCipherError::CipherNotLogin)
430        ));
431    }
432}