1use 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#[derive(Debug, Error)]
16pub enum RotationCipherError {
17 #[error("cipher does not belong to the connector's organization")]
19 CipherOrganizationMismatch,
20
21 #[error("cipher is not a login")]
23 CipherNotLogin,
24
25 #[error("cipher is restricted")]
27 CipherRestricted,
28
29 #[error("cipher has no item key")]
32 CipherWithoutItemKey,
33
34 #[error("re-encrypted cipher carries a data blob")]
36 CipherDataPresent,
37
38 #[error("cryptographic operation failed")]
40 Crypto(#[from] bitwarden_crypto::CryptoError),
41
42 #[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
57pub 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
92pub 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#[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 const ORG_KEY_B64: &str =
122 "a4oGKmzuPXlJr6ANfFLy9DHw7lmaCo47mc01fROBKMUklVouVlwvgGsX1Sz9D9JHKbqOku/K25akClXWyp8a4g==";
123
124 pub(super) const KEYED_LOGIN: &str =
126 include_str!("../../tests/fixtures/ciphers/keyed_login.json");
127 pub(super) const KEYED_LOGIN_WITH_ATTACHMENT: &str =
129 include_str!("../../tests/fixtures/ciphers/keyed_login_with_attachment.json");
130 pub(super) const KEYLESS_LOGIN: &str =
132 include_str!("../../tests/fixtures/ciphers/keyless_login.json");
133 pub(super) const RESTRICTED_LOGIN: &str =
135 include_str!("../../tests/fixtures/ciphers/restricted_login.json");
136
137 pub(super) fn vector_json(vector: &str) -> serde_json::Value {
139 serde_json::from_str(vector).expect("vector is JSON")
140 }
141
142 pub(super) fn keyed_login_json() -> serde_json::Value {
144 vector_json(KEYED_LOGIN)
145 }
146
147 pub(super) fn attachments_json() -> serde_json::Value {
149 vector_json(KEYED_LOGIN_WITH_ATTACHMENT)["attachments"].clone()
150 }
151
152 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 pub(super) fn decrypt_body_field(
170 store: &KeyStore<KeySlotIds>,
171 body: &serde_json::Value,
172 pointer: &str,
173 ) -> String {
174 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 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 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 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}