1use bitwarden_api_api::models::CipherRequestModel;
6use bitwarden_core::{OrganizationId, key_management::KeySlotIds};
7use bitwarden_crypto::KeyStore;
8use bitwarden_pam::{
9 RotationCipherError, RotationFailureCode, RotationSyncState, cipher_has_password,
10 rotate_cipher_password,
11};
12use bitwarden_vault::Cipher;
13use uuid::Uuid;
14
15use super::{AbortReason, Attempt, CipherWritten, Halt, abort_from_api, halt_from_api};
16use crate::{api::models::ApiError, error::SafeDetail, executor::retry::with_retries};
17
18#[derive(Clone, Copy)]
20enum Stage {
21 PreFlight,
23 PreWrite,
25}
26
27impl Stage {
28 fn sync(self) -> RotationSyncState {
29 match self {
30 Stage::PreFlight => RotationSyncState::TargetUnchanged,
31 Stage::PreWrite => RotationSyncState::TargetUpdated,
32 }
33 }
34}
35
36macro_rules! stage_reason {
38 ($stage:expr, $suffix:literal) => {
39 match $stage {
40 Stage::PreFlight => concat!("pre_flight_", $suffix),
41 Stage::PreWrite => concat!("pre_write_", $suffix),
42 }
43 };
44}
45
46impl Attempt<'_> {
47 pub(super) async fn pre_flight(&self) -> Result<(), Halt> {
50 let cipher = fetch_checked_cipher(self, Stage::PreFlight).await?;
51
52 rotate_cipher(
54 &self.api.key_store,
55 self.ctx.organization_id,
56 cipher,
57 PRE_FLIGHT_PLACEHOLDER_PASSWORD,
58 Stage::PreFlight,
59 )?;
60 Ok(())
61 }
62
63 pub(super) async fn write_cipher(&self, new_password: &str) -> Result<CipherWritten, Halt> {
66 let Self {
67 job,
68 ctx,
69 api,
70 attempt_id,
71 ..
72 } = *self;
73
74 let cipher = fetch_checked_cipher(self, Stage::PreWrite).await?;
76
77 tracing::debug!(attempt_id = %attempt_id, cipher_id = %job.cipher_id(), "cipher fetched");
78
79 let request = rotate_cipher(
80 &api.key_store,
81 ctx.organization_id,
82 cipher,
83 new_password,
84 Stage::PreWrite,
85 )?;
86
87 let result = with_retries(
88 &ctx.retry_cfg,
89 None, || {
91 let request = request.clone();
92 async move {
93 api.put_cipher(attempt_id, request)
94 .await
95 .map_err(|e| (e.retry_class(), e))
96 }
97 },
98 )
99 .await;
100
101 match result {
102 Ok(()) => {
103 tracing::info!(
104 attempt_id = %attempt_id,
105 cipher_id = %job.cipher_id(),
106 "step 5: cipher written"
107 );
108 }
109 Err(ApiError::Rejected { .. }) => match cipher_write_landed(self, new_password).await {
112 Ok(true) => {
113 tracing::info!(
114 attempt_id = %attempt_id,
115 cipher_id = %job.cipher_id(),
116 "step 5: cipher write confirmed after 409"
117 );
118 }
119 Ok(false) => {
120 return Err(Halt::report(
121 RotationFailureCode::CipherWriteRejected,
122 RotationSyncState::TargetUpdated,
123 ));
124 }
125 Err(reason) => return Err(Halt::Abort(reason)),
126 },
127 Err(e) => return Err(halt_from_api(e, RotationSyncState::TargetUpdated)),
129 }
130 Ok(CipherWritten(()))
131 }
132}
133
134async fn fetch_cipher(attempt: &Attempt<'_>) -> Result<Cipher, ApiError> {
136 let Attempt {
137 api,
138 ctx,
139 attempt_id,
140 ..
141 } = *attempt;
142 with_retries(&ctx.retry_cfg, None, move || async move {
143 api.get_cipher(attempt_id)
144 .await
145 .map_err(|e| (e.retry_class(), e))
146 })
147 .await
148}
149
150async fn fetch_checked_cipher(attempt: &Attempt<'_>, stage: Stage) -> Result<Cipher, Halt> {
152 let cipher = fetch_cipher(attempt)
153 .await
154 .map_err(|e| halt_from_api(e, stage.sync()))?;
155
156 if cipher.id.map(Uuid::from) != Some(attempt.job.cipher_id()) {
157 return Err(Halt::report_detail(
158 RotationFailureCode::SealedDataRejected,
159 SafeDetail::from_seal_rejection(stage_reason!(stage, "cipher_id_mismatch")),
160 stage.sync(),
161 ));
162 }
163 Ok(cipher)
164}
165
166fn rotate_cipher(
168 key_store: &KeyStore<KeySlotIds>,
169 organization_id: OrganizationId,
170 cipher: Cipher,
171 new_password: &str,
172 stage: Stage,
173) -> Result<CipherRequestModel, Halt> {
174 rotate_cipher_password(key_store, organization_id, cipher, new_password)
175 .map_err(|e| cipher_halt(&e, stage))
176}
177
178async fn cipher_write_landed(
181 attempt: &Attempt<'_>,
182 new_password: &str,
183) -> Result<bool, AbortReason> {
184 match fetch_cipher(attempt).await {
185 Ok(cipher) => {
186 Ok(cipher_has_password(&attempt.api.key_store, &cipher, new_password).unwrap_or(false))
187 }
188 Err(e) => abort_from_api(&e).map_or(Ok(false), Err),
189 }
190}
191
192const PRE_FLIGHT_PLACEHOLDER_PASSWORD: &str = "pre-flight-placeholder";
194
195fn cipher_halt(err: &RotationCipherError, stage: Stage) -> Halt {
198 use RotationCipherError as E;
199
200 let reason = match (err, stage) {
201 (E::CipherDataPresent | E::Json(_), Stage::PreWrite) => {
203 return Halt::report(
204 RotationFailureCode::CipherEncryptFailed,
205 RotationSyncState::TargetUpdated,
206 );
207 }
208 (E::CipherDataPresent | E::Json(_), Stage::PreFlight) => "pre_flight_cipher_write_invalid",
209 (E::CipherOrganizationMismatch, _) => stage_reason!(stage, "cipher_org_mismatch"),
210 (E::CipherNotLogin, _) => stage_reason!(stage, "cipher_not_login"),
211 (E::CipherRestricted, _) => stage_reason!(stage, "cipher_restricted"),
212 (E::CipherWithoutItemKey, _) => stage_reason!(stage, "cipher_without_item_key"),
213 (E::Crypto(_), _) => stage_reason!(stage, "cipher_decrypt_failed"),
216 };
217 Halt::report_detail(
218 RotationFailureCode::SealedDataRejected,
219 SafeDetail::from_seal_rejection(reason),
220 stage.sync(),
221 )
222}
223
224#[cfg(test)]
225mod tests {
226 use std::sync::{Arc, Mutex};
227
228 use bitwarden_api_api::models::CipherMiniResponseModel;
229 use wiremock::ResponseTemplate;
230
231 use super::*;
232 use crate::{
233 executor::rotation::tests::{Fixture, json_response},
234 test_support::{
235 KEYED_LOGIN_WITH_ATTACHMENT, KEYLESS_LOGIN, RESTRICTED_LOGIN, attachments_json,
236 decrypt_body_field, keyed_login_json, org_store, test_org_id, vector_json,
237 },
238 };
239
240 fn report_parts(halt: Halt) -> (RotationFailureCode, Option<String>, RotationSyncState) {
242 match halt {
243 Halt::Report { code, detail, sync } => {
244 (code, detail.map(|d| d.as_str().to_owned()), sync)
245 }
246 Halt::Abort(_) => panic!("expected a reported halt, got an abort"),
247 }
248 }
249
250 fn halt_of<T>(result: Result<T, Halt>) -> Halt {
252 match result {
253 Err(halt) => halt,
254 Ok(_) => panic!("expected a halt"),
255 }
256 }
257
258 fn rejected(reason: &str) -> Option<String> {
259 Some(format!("sealed data rejected: {reason}"))
260 }
261
262 fn cipher_from(json: serde_json::Value) -> Cipher {
263 let model: CipherMiniResponseModel = serde_json::from_value(json).expect("model");
264 Cipher::try_from(model).expect("cipher")
265 }
266
267 fn bad_json() -> RotationCipherError {
268 RotationCipherError::Json(serde_json::from_str::<()>("x").unwrap_err())
269 }
270
271 #[test]
276 fn cipher_halt_write_invalid_differs_by_stage() {
277 use RotationCipherError as E;
278
279 for err in [E::CipherDataPresent, bad_json()] {
280 assert_eq!(
281 report_parts(cipher_halt(&err, Stage::PreFlight)),
282 (
283 RotationFailureCode::SealedDataRejected,
284 rejected("pre_flight_cipher_write_invalid"),
285 RotationSyncState::TargetUnchanged
286 ),
287 "{err} at pre-flight"
288 );
289 assert_eq!(
290 report_parts(cipher_halt(&err, Stage::PreWrite)),
291 (
292 RotationFailureCode::CipherEncryptFailed,
293 None,
294 RotationSyncState::TargetUpdated
295 ),
296 "{err} at pre-write"
297 );
298 }
299 }
300
301 fn rotate(json: serde_json::Value, stage: Stage) -> Result<CipherRequestModel, Halt> {
304 rotate_cipher(
305 &org_store(),
306 test_org_id(),
307 cipher_from(json),
308 "new-pw",
309 stage,
310 )
311 }
312
313 #[test]
314 fn rotate_cipher_of_cipher_with_item_key_and_attachments_succeeds() {
315 let json = vector_json(KEYED_LOGIN_WITH_ATTACHMENT);
316 let request = rotate(json, Stage::PreFlight).ok().expect("rotates");
317
318 let body = serde_json::to_value(&request).unwrap();
319 assert_eq!(
320 decrypt_body_field(&org_store(), &body, "/login/password"),
321 "new-pw"
322 );
323 assert_eq!(body["attachments2"].as_object().map(|a| a.len()), Some(1));
324 }
325
326 #[test]
328 fn rotate_cipher_rejects_unrotatable_ciphers_at_both_stages() {
329 use bitwarden_crypto::{KeyEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm};
330
331 let other_org = {
332 let mut json = keyed_login_json();
333 json["organizationId"] = uuid::Uuid::new_v4().to_string().into();
334 json
335 };
336 let non_login = {
337 let mut json = keyed_login_json();
338 json["type"] = 2.into();
339 json
340 };
341 let undecryptable = {
343 let other = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
344 let foreign = "x".encrypt_with_key(&other).unwrap().to_string();
345 let mut json = keyed_login_json();
346 json["notes"] = foreign.into();
347 json
348 };
349 let keyless = vector_json(KEYLESS_LOGIN);
351 let keyless_with_attachments = {
352 let mut json = vector_json(KEYLESS_LOGIN);
353 json["attachments"] = attachments_json();
354 json
355 };
356
357 let cases = [
358 ("other organization", other_org, "cipher_org_mismatch"),
359 ("non-login", non_login, "cipher_not_login"),
360 (
361 "restricted",
362 vector_json(RESTRICTED_LOGIN),
363 "cipher_restricted",
364 ),
365 (
366 "undecryptable field",
367 undecryptable,
368 "cipher_decrypt_failed",
369 ),
370 ("without item key", keyless, "cipher_without_item_key"),
371 (
372 "attachments without item key",
373 keyless_with_attachments,
374 "cipher_without_item_key",
375 ),
376 ];
377
378 for (label, json, reason) in cases {
379 for (stage, prefix) in [
380 (Stage::PreFlight, "pre_flight"),
381 (Stage::PreWrite, "pre_write"),
382 ] {
383 let halt = halt_of(rotate(json.clone(), stage));
384 assert_eq!(
385 report_parts(halt),
386 (
387 RotationFailureCode::SealedDataRejected,
388 rejected(&format!("{prefix}_{reason}")),
389 stage.sync()
390 ),
391 "{label} at {prefix}"
392 );
393 }
394 }
395 }
396
397 async fn serving(json: serde_json::Value) -> Fixture {
399 Fixture::new(json_response(json), ResponseTemplate::new(200)).await
400 }
401
402 fn not_json() -> ResponseTemplate {
403 ResponseTemplate::new(200)
404 .set_body_string("NOT JSON")
405 .insert_header("content-type", "application/json")
406 }
407
408 #[tokio::test]
411 async fn write_cipher_writes_typed_cipher_with_history() {
412 let json = keyed_login_json();
413 let original_key = json["key"].as_str().unwrap().to_owned();
414 assert_eq!(
415 json["revisionDate"],
416 crate::test_support::CIPHER_REVISION_DATE
417 );
418 let fixture = serving(json).await;
419
420 assert!(fixture.attempt().write_cipher("new-pw").await.is_ok());
421
422 let puts = fixture.requests_to("PUT", "/cipher").await;
423 assert_eq!(puts.len(), 1);
424 let body: serde_json::Value = serde_json::from_slice(&puts[0].body).expect("PUT body");
425 let dec = |p| decrypt_body_field(&fixture.store, &body, p);
426
427 assert_eq!(body["key"], original_key.as_str());
429
430 assert_eq!(dec("/login/password"), "new-pw");
432 assert_eq!(dec("/passwordHistory/0/password"), "old-password");
433 assert_eq!(dec("/passwordHistory/1/password"), "older-password");
434 assert!(body["login"]["passwordRevisionDate"].is_string());
435 assert_eq!(body["lastKnownRevisionDate"], "2024-06-01T12:00:00Z");
438 assert!(body["data"].is_null());
439 assert_eq!(body["organizationId"], test_org_id().to_string());
440 assert_eq!(body["type"], 1);
441 assert_eq!(dec("/login/username"), "svc-account");
442 assert_eq!(dec("/login/uris/0/uri"), "https://db.example.com");
443 assert_eq!(dec("/name"), "Prod database");
444 }
445
446 #[tokio::test]
448 async fn write_cipher_protocol_error_reports_internal_target_updated() {
449 let fixture = Fixture::new(not_json(), ResponseTemplate::new(200)).await;
450
451 let halt = halt_of(fixture.attempt().write_cipher("new-pw").await);
452 assert_eq!(
453 report_parts(halt),
454 (
455 RotationFailureCode::Internal,
456 None,
457 RotationSyncState::TargetUpdated
458 )
459 );
460 assert!(fixture.requests_to("PUT", "/cipher").await.is_empty());
461 }
462
463 #[tokio::test]
464 async fn write_cipher_rejects_cipher_id_mismatch_as_target_updated() {
465 let mut json = keyed_login_json();
466 json["id"] = uuid::Uuid::new_v4().to_string().into();
467 let fixture = serving(json).await;
468
469 let halt = halt_of(fixture.attempt().write_cipher("new-pw").await);
470 assert_eq!(
471 report_parts(halt),
472 (
473 RotationFailureCode::SealedDataRejected,
474 rejected("pre_write_cipher_id_mismatch"),
475 RotationSyncState::TargetUpdated
476 )
477 );
478 assert!(fixture.requests_to("PUT", "/cipher").await.is_empty());
479 }
480
481 struct ConflictAfterCommit(Arc<Mutex<Option<serde_json::Value>>>);
484
485 impl wiremock::Respond for ConflictAfterCommit {
486 fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
487 *self.0.lock().unwrap() =
488 Some(serde_json::from_slice(&request.body).expect("PUT body"));
489 ResponseTemplate::new(409)
490 }
491 }
492
493 struct VaultAfterPut(Arc<Mutex<Option<serde_json::Value>>>);
497
498 impl wiremock::Respond for VaultAfterPut {
499 fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
500 let mut json = keyed_login_json();
501 if let Some(put) = self.0.lock().unwrap().as_ref() {
502 json["login"]["password"] = put["login"]["password"].clone();
503 }
504 json_response(json)
505 }
506 }
507
508 #[tokio::test]
510 async fn write_cipher_409_with_new_password_in_vault_succeeds() {
511 let put = Arc::new(Mutex::new(None));
512 let fixture = Fixture::new(VaultAfterPut(Arc::clone(&put)), ConflictAfterCommit(put)).await;
513
514 assert!(fixture.attempt().write_cipher("new-pw").await.is_ok());
515 assert_eq!(fixture.requests_to("PUT", "/cipher").await.len(), 1);
516 }
517
518 #[tokio::test]
520 async fn write_cipher_409_with_old_password_in_vault_is_rejected() {
521 let fixture = Fixture::new(
522 json_response(keyed_login_json()),
523 ResponseTemplate::new(409),
524 )
525 .await;
526
527 let halt = halt_of(fixture.attempt().write_cipher("new-pw").await);
528 assert_eq!(
529 report_parts(halt),
530 (
531 RotationFailureCode::CipherWriteRejected,
532 None,
533 RotationSyncState::TargetUpdated
534 )
535 );
536 assert_eq!(fixture.requests_to("PUT", "/cipher").await.len(), 1);
537 }
538}