Skip to content

[PM-33107] Blob encryption key rotation migration#1188

Open
shane-melton wants to merge 6 commits into
vault/pm-32695/blob-encryptfrom
vault/pm-31107/blob-key-rotation-migration
Open

[PM-33107] Blob encryption key rotation migration#1188
shane-melton wants to merge 6 commits into
vault/pm-32695/blob-encryptfrom
vault/pm-31107/blob-key-rotation-migration

Conversation

@shane-melton

@shane-melton shane-melton commented Jun 12, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

PM-33107

Stacked on top of #1122 (PM-32695 Cipher blob encryption).

📔 Objective

Make key rotation upgrade every individual-vault cipher to the sealed-blob format. Key rotation always lands the account on the V2 security state, so all individual ciphers must end up blob-encrypted afterward. reencrypt_ciphers now handles three cases:

  • Already blob-encrypted (with a per-item key): only re-wrap the per-item cipher key under the new user key; the sealed blob payload is left untouched (no decrypt/re-encrypt).
  • Legacy with a per-item key: re-wrap the key under the new user key, decrypt, then re-seal as a blob.
  • Legacy keyless: decrypt under the current key and re-seal as a blob (sealing generates a fresh per-item key).

Supporting changes:

  • decrypt_blob_cipher now takes an explicit wrapping_key slot instead of always deriving it from cipher.key_identifier(). During rotation the CEK is wrapped under a Local slot holding the new user key, so the wrapping key can no longer be assumed to be the user/organization key.
  • EncryptMode::Blob routes through encrypt_blob_cipher_with_wrapping_key, respecting the caller-supplied key slot so rotation can target the new-user-key slot.
  • EncryptMode and Cipher::is_blob_encrypted() are made public for use by the rotation crate.
  • New tests covering all three reencryption paths (keyless-legacy upgrade, keyed-legacy upgrade, and existing-blob rewrap-without-re-encrypt).

🚨 Breaking Changes

None for clients. EncryptMode and Cipher::is_blob_encrypted() become public, and decrypt_blob_cipher gains a wrapping_key parameter (crate-internal).

@shane-melton shane-melton marked this pull request as ready for review June 12, 2026 22:26
@shane-melton shane-melton requested review from a team as code owners June 12, 2026 22:26
@shane-melton shane-melton requested review from mzieniukbw and nick-livefront and removed request for a team June 12, 2026 22:26
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR extends key rotation to upgrade every individual-vault cipher to the sealed-blob format, since rotation always lands the account on V2. The three-case branching in reencrypt_ciphers (already-blob, keyed-legacy, keyless-legacy) is well structured and the new test coverage exercises each path. decrypt_blob_cipher now takes an explicit wrapping_key, which is the right plumbing for rotation since the new user key lives in a Local slot rather than the natural User/Organization slot. The EncryptMode::Blob routing through encrypt_blob_cipher_with_wrapping_key and the visibility changes (EncryptMode, Cipher::is_blob_encrypted()) line up with the rotation crate's needs.

Code Review Details
  • ❓ : Lenient decryption during keyed-legacy → blob upgrade may silently null out fields that fail to decrypt and discard the original ciphertext on upload
    • crates/bitwarden-user-crypto-management/src/key_rotation/data.rs:161

Comment on lines +161 to +180
fn decrypt_for_blob_upgrade(
cipher: &bitwarden_vault::Cipher,
current_key: SymmetricKeySlotId,
new_key: SymmetricKeySlotId,
ctx: &mut KeyStoreContext<KeySlotIds>,
) -> Result<CipherView, DataReencryptionError> {
if cipher.key.is_some() {
let mut rewrapped = cipher.clone();
rewrapped
.rewrap_cipher_key(current_key, new_key, ctx)
.map_err(|_| DataReencryptionError::CipherKeyRewrap)?;
rewrapped
.decrypt(ctx, new_key)
.map_err(|_| DataReencryptionError::Decryption)
} else {
cipher
.decrypt(ctx, current_key)
.map_err(|_| DataReencryptionError::Decryption)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: Should the blob upgrade path use strict decryption to avoid silently losing data on per-field decryption errors?

Details

Both branches of decrypt_for_blob_upgrade go through Cipher::decrypt, which routes legacy ciphers through lenient_decrypt_cipher_view. That helper applies .ok().flatten() to each field (login, notes, card, password_history, etc.), so a field that fails to decrypt is silently turned into None and then re-sealed as None in the new blob — permanently discarding the original ciphertext once the rotation payload is uploaded.

For keyless-legacy ciphers this is the same exposure that existed before. The new risk is for keyed-legacy ciphers: previously they only had their cipher key rewrapped (ciphertext preserved bit-for-bit), but they now also go through lenient decrypt + re-seal. A keyed-legacy cipher with a single corrupt field will round-trip into a blob with that field nulled out.

StrictDecrypt<Cipher> already exists in bitwarden-vault for precisely this reason (used by edit/share flows). Is it intentional to keep rotation on the lenient path, or should the rotation crate adopt strict decryption so a single bad field aborts the rotation instead of silently rewriting the cipher?

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

🔍 SDK Breaking Change Detection

SDK Version: vault/pm-31107/blob-key-rotation-migration (a1eb6ea)

⚠️ If breaking changes are detected, a corresponding pull request addressing them must be ready for merge in the affected client repository.

Client Status Details
android ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/android. - View Details
typescript ✅ No breaking changes detected Compilation passed with new SDK version - View Details

Breaking change detection uses the build of the SDK from this branch, including any incompatibities pre-existing on or merged into this branch. Check the workflow logs to confirm.
Results update as workflows complete.

@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.25926% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 84.82%. Comparing base (9c98988) to head (9235aa1).

Files with missing lines Patch % Lines
crates/bitwarden-vault/src/cipher/cipher.rs 85.71% 1 Missing ⚠️
Additional details and impacted files
@@                       Coverage Diff                       @@
##           vault/pm-32695/blob-encrypt    #1188      +/-   ##
===============================================================
+ Coverage                        84.75%   84.82%   +0.06%     
===============================================================
  Files                              448      448              
  Lines                            60594    60680      +86     
===============================================================
+ Hits                             51359    51472     +113     
+ Misses                            9235     9208      -27     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@shane-melton shane-melton changed the title [PM-31107] Blob encryption key rotation migration [PM-33107] Blob encryption key rotation migration Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants