Skip to main content

bitwarden_access_connector/executor/rotation/
cipher.rs

1//! The vault side of a rotation attempt: the pre-flight dry run and the cipher write (including
2//! the 409 re-check), plus fetching, checking and re-encrypting the cipher. The stage fixes how a
3//! rejection is reported.
4
5use 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/// Where a cipher is checked, which fixes the sync state and the reason strings.
19#[derive(Clone, Copy)]
20enum Stage {
21    /// Before the target is touched.
22    PreFlight,
23    /// After the target is rotated, just before the cipher write.
24    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
36/// Picks the stage's `'static` reason string for `$suffix`.
37macro_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    /// Pre-flight cipher check, before the target is touched, so an unrotatable cipher (wrong
48    /// organization or type, restricted, undecryptable) fails as target_unchanged.
49    pub(super) async fn pre_flight(&self) -> Result<(), Halt> {
50        let cipher = fetch_checked_cipher(self, Stage::PreFlight).await?;
51
52        // Dry run of the cipher write with a placeholder password; the request is discarded.
53        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    /// Fetches the cipher and writes the new password. Not bounded by execute_by; the session's
64    /// bearer refresh handles token expiry.
65    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        // Repeats the pre-flight check; the target is already rotated, so this is target_updated.
75        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, // no deadline
90            || {
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            // A retried PUT repeats its `lastKnownRevisionDate`, so a 409 can follow a first try
110            // that committed with its response lost. Only the vault's contents settle it.
111            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            // The target credential already changed.
128            Err(e) => return Err(halt_from_api(e, RotationSyncState::TargetUpdated)),
129        }
130        Ok(CipherWritten(()))
131    }
132}
133
134/// Fetches the cipher with retries. Callers handle failure, since the sync state differs.
135async 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
150/// Fetches the cipher and checks that it is the claim's.
151async 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
166/// Re-encrypts `cipher` with `new_password`, mapping a failure to the stage's halt.
167fn 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
178/// Whether the vault's cipher already holds `new_password`, so a rejected write did land. Any
179/// failure to tell counts as not landed; only an abort is an error.
180async 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
192/// Stands in for the new password in the pre-flight dry run.
193const PRE_FLIGHT_PLACEHOLDER_PASSWORD: &str = "pre-flight-placeholder";
194
195/// Maps a cipher error to the stage's halt, with a fixed, content-free reason. A write-invalid
196/// error only gets its own reason at pre-flight; at pre-write it is `CipherEncryptFailed`.
197fn cipher_halt(err: &RotationCipherError, stage: Stage) -> Halt {
198    use RotationCipherError as E;
199
200    let reason = match (err, stage) {
201        // The re-encrypted cipher produced an unusable request.
202        (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        // A `Crypto` error can't be split into decrypt or encrypt. Pre-flight runs the same
214        // encryption on a placeholder password, so at either stage treat it as a rejected cipher.
215        (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    /// The parts of a reported halt: code, detail and sync state. Panics on an abort.
241    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    /// The halt of a step that was expected to fail.
251    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    // ---- cipher_halt ----
272
273    /// A write-invalid error only gets its own reason at pre-flight; at pre-write it is
274    /// `CipherEncryptFailed`. The other errors are covered by `rotate_cipher` below.
275    #[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    // ---- rotate_cipher ----
302
303    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    /// A cipher that cannot be rotated is rejected with the stage's reason.
327    #[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        // Lenient decryption would drop the field and the write would wipe it.
342        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        // A keyless cipher would make the vault generate an item key.
350        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    /// A fixture serving `json` for every `GET /cipher`, for the keyed login claim.
398    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    // ---- Attempt::write_cipher ----
409
410    #[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        // The item key is sent back unchanged, and the new fields decrypt under it.
428        assert_eq!(body["key"], original_key.as_str());
429
430        // The written password is the new one; the old ones are in history.
431        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        // The wire format drops the fraction of `CIPHER_REVISION_DATE`; the server's revision
436        // check allows a one-second difference.
437        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    /// A `get_cipher` protocol error after a successful rotate reports `target_updated`.
447    #[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    /// Answers `PUT /cipher` with a 409 and keeps the body, as a write that committed and was
482    /// retried would see.
483    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    /// Serves the cipher as the vault holds it: the fixture, carrying the password written by the
494    /// PUT (if any). The PUT keeps the item key, so its encrypted password decrypts in the
495    /// fixture.
496    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    /// A 409 on the PUT whose write did land (the vault holds the new password) is a success.
509    #[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    /// A 409 on the PUT with the old password still in the vault is a rejected write.
519    #[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}