Skip to content

[PM-41073] Remove nested EncStrings on decrypted CipherViews - #1353

Open
nikwithak wants to merge 21 commits into
mainfrom
vault/pm-41073
Open

[PM-41073] Remove nested EncStrings on decrypted CipherViews#1353
nikwithak wants to merge 21 commits into
mainfrom
vault/pm-41073

Conversation

@nikwithak

@nikwithak nikwithak commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-41073

📔 Objective

When decrypting a Cipher into a CipherView, we currently leave the following fields in an encrypted state as an EncString, for later decryption. This breaks the contract of the Decryptable trait, which should leave us with a fully decrypted object. This PR updates those implementations and types to be fully decrypted and exposed when in a decrypted CipherView state.

Currently in draft, as additional changes may be needed if any issues arise while working on the client PRs.

🚨 Breaking Changes

Yes - this has breaking changes with the SDK, by changing the contracts with the decrypted types. Changes will be needed in all clients currently using the SDK to consume the new approach.

Clients will need to update their usage of Fido2 credentials and TOTP tokens, and can remove the extra step of decrypting them on access, and use the values, which are now decrypted fully, directly. Any clients directly using the cipher key will also need to update that use, as it is no longer wrapped by the user/org key inside a CipherView.

I'll update this with links to those PRs once they are ready.

Clients PR (DRAFT / WIP): bitwarden/clients#22245

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🔍 SDK Breaking Change Detection

SDK Version: vault/pm-41073 (eb5e85a)

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

Client Status Details
typescript ❌ Breaking changes detected Compilation failed with new SDK version. A corresponding pull request addressing the breaking changes must be ready for merge in bitwarden/clients. - View 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

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.

@nikwithak
nikwithak marked this pull request as ready for review August 5, 2026 22:34
@nikwithak
nikwithak requested review from a team as code owners August 5, 2026 22:34
@nikwithak
nikwithak requested a review from dani-garcia August 5, 2026 22:34
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the removal of nested EncStrings from decrypted cipher views across bitwarden-vault, bitwarden-fido, bitwarden-exporters, and bitwarden-user-crypto-management. The refactor is coherent: CipherView.key, AttachmentView.key, Fido2CredentialView.key_value, and LoginListView.totp now hold plaintext, and re-wrapping is deferred to encrypt time via load_cipher_key_slot / encrypt_composite, which correctly removes the previously documented Decryptable/CompositeEncryptable contract violations. The key-rotation, move_to_organization, blob, and legacy field-encryption paths were traced and appear consistent under the new model. Findings below concern the attachment-failure path, an unused key field that now leaks raw key material, and silent passkey loss on lenient decrypt.

Code Review Details
  • ⚠️ : Failed attachment decryptions now drop the key, so they masquerade as legacy v1 attachments (has_old_attachments, encryption_version(), and AttachmentFile::decrypt all misbehave)
    • crates/bitwarden-vault/src/cipher/attachment.rs:289
  • ⚠️ : CipherListView.key now serializes the raw content-encryption key to every list consumer despite having no remaining reader in the workspace
    • crates/bitwarden-vault/src/cipher/cipher.rs:541
  • ⚠️ : Undecryptable FIDO2 credentials are silently dropped by Login::decrypt and permanently deleted on the next save (previously the ciphertext was preserved)
    • crates/bitwarden-vault/src/cipher/login.rs:489-492
  • ♻️ : reencrypt_cipher_keys re-encrypts nothing and ignores _new_wrapping_key; several other public fns retain dead _ctx parameters
    • crates/bitwarden-vault/src/cipher/cipher.rs:970-976

Broader note (not blocking, no inline comment): this change materially widens the plaintext key surface crossing the WASM/UniFFI boundary — raw CEKs on CipherView/CipherListView/AttachmentView and raw passkey private keys on every Fido2CredentialView. The AttachmentView.decrypted_key precedent was #[cfg(feature = "wasm")]-gated and explicitly marked temporary; this exposure is unconditional and permanent. Worth confirming this is covered by the security review for PM-41073 before the client PRs land.

key: attachment.key.clone(),
#[cfg(feature = "wasm")]
decrypted_key: None,
key: None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: Dropping the key on failed attachments makes them indistinguishable from legacy v1 attachments.

Details and fix

Previously this branch preserved key: attachment.key.clone() (the wrapped key), so a failure was only about file_name. Now key: None has three downstream effects:

  1. AttachmentView::encryption_version() returns LegacyNoKeyV1 instead of AttachmentKeyV2.
  2. CipherView::to_list_view computes has_old_attachments = all_attachments().any(|att| att.key.is_none()) and chains attachment_decryption_failures, so any cipher with one failed attachment now reports has_old_attachments == true.
  3. AttachmentFile::decrypt takes the else (legacy v1) branch and tries to decrypt contents with the user/org key — silently wrong rather than erroring.

Attachment::decrypt can fail on file_name.decrypt while the attachment key unwraps fine, so the key is often still recoverable. Consider unwrapping the key independently of the file name so the failure view keeps a usable key:

Err(e) => {
    let key = attachment.key.as_ref().and_then(|k| {
        let id = ctx.unwrap_symmetric_key(key, k).ok()?;
        #[allow(deprecated)]
        Some(ctx.dangerous_get_symmetric_key(id).ok()?.to_base64().to_string())
    });
    failures.push(AttachmentView { /* ... */ key });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems reasonable. This is a failed decryption and we cannot reasonable place a key here. Ideally we would have an enum of different struct representations (failed, not failed) but this is OK too.


/// Temporary, required to support calculating TOTP from CipherListView.
pub key: Option<EncString>,
pub key: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: CipherListView.key now ships the raw content-encryption key to clients but has no remaining consumer.

Details and fix

The removed doc comment said this field existed only "to support calculating TOTP from CipherListView". get_totp_key no longer reads key, and no other code in the workspace reads CipherListView.key (checked totp.rs, totp_client.rs, cipher_client/*, bitwarden-uniffi).

The field is populated in to_list_view and the three *_decrypt_cipher_list_view fns with dangerous_get_symmetric_key(...).to_base64(), so every decrypt_list call now serializes the plaintext CEK for every cipher in the vault across the WASM/UniFFI boundary into a non-zeroizing String.

Since this PR is already a breaking change, removing key from CipherListView avoids the exposure entirely. If a client does still need it, it would be worth calling out explicitly in the breaking-change notes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Accepted from KM side.

Comment on lines +489 to +492
fido2_credentials: self
.fido2_credentials
.as_ref()
.and_then(|c| c.decrypt(ctx, key).ok()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: Undecryptable FIDO2 credentials are now silently dropped and permanently deleted on the next save.

Details and fix

Before this change LoginView.fido2_credentials held the ciphertext, so a credential that failed to decrypt survived a decrypt → edit → encrypt round trip untouched. Now .ok() turns any decryption failure into None, and LoginView::encrypt_composite writes fido2_credentials: None back to the server — the passkey is gone.

This is more destructive than the neighbouring lenient fields: notes/uris/totp failing to decrypt is visible in the UI, whereas a missing passkey is silent. The codebase already tracks this class of failure elsewhere (attachment_decryption_failures, MalformedField).

Consider propagating the error, or surfacing the failure so callers can refuse to overwrite:

fido2_credentials: self
    .fido2_credentials
    .as_ref()
    .map(|c| c.decrypt(ctx, key))
    .transpose()?,

Comment on lines +970 to +976
/// Re-encrypt the cipher key(s) using a new wrapping key.
///
/// If the cipher has a cipher key, it will be re-encrypted with the new wrapping key.
/// Otherwise, the cipher will re-encrypt all attachment keys and FIDO2 credential keys
/// If any attachment is missing a key, returns an error because the attachment
/// keys cannot be re-encrypted.
pub fn reencrypt_cipher_keys(
&mut self,
ctx: &mut KeyStoreContext<KeySlotIds>,
new_wrapping_key: SymmetricKeySlotId,
_new_wrapping_key: SymmetricKeySlotId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ DEBT: reencrypt_cipher_keys no longer re-encrypts anything and ignores its only parameter.

Details and fix

The body is now just the AttachmentsWithoutKeys validation; _new_wrapping_key is unused. Callers (encrypt_cipher_for_rotation, and any client via move_to_organization) will reasonably assume passing a key does something. Since this PR is already breaking, renaming to something like validate_attachment_keys() and dropping the parameter would make the new "rewrap happens at encrypt time" model explicit.

Same pattern applies to the other placeholder _ctx parameters kept for signature compatibility (totp.rs:88 generate_totp_cipher_view, bitwarden-fido/src/types.rs:73 from_cipher_view, bitwarden-fido/src/lib.rs:78 CipherViewContainer::new) — worth removing in the same breaking pass rather than leaving dead arguments in the public API.

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.04819% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.18%. Comparing base (fbd2167) to head (3b12971).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
crates/bitwarden-vault/src/cipher/login.rs 77.02% 17 Missing ⚠️
crates/bitwarden-exporters/src/models.rs 37.50% 5 Missing ⚠️
crates/bitwarden-fido/src/authenticator.rs 77.77% 4 Missing ⚠️
...es/bitwarden-vault/src/cipher/cipher_client/mod.rs 55.55% 4 Missing ⚠️
crates/bitwarden-vault/src/cipher/cipher.rs 97.16% 3 Missing ⚠️
crates/bitwarden-exporters/src/export.rs 0.00% 1 Missing ⚠️
crates/bitwarden-fido/src/client_fido.rs 0.00% 1 Missing ⚠️
crates/bitwarden-fido/src/types.rs 0.00% 1 Missing ⚠️
crates/bitwarden-vault/src/cipher/attachment.rs 97.05% 1 Missing ⚠️
...den-vault/src/cipher/cipher_client/admin/create.rs 0.00% 1 Missing ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1353      +/-   ##
==========================================
- Coverage   86.27%   86.18%   -0.09%     
==========================================
  Files         500      500              
  Lines       73325    73129     -196     
==========================================
- Hits        63259    63028     -231     
- Misses      10066    10101      +35     

☔ 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.

@nikwithak

Copy link
Copy Markdown
Contributor Author

Draft clients PR Here: bitwarden/clients#22245 (still WIP)

Comment thread crates/bitwarden-vault/src/totp.rs Outdated
Comment thread crates/bitwarden-vault/src/totp.rs Outdated
Comment thread crates/bitwarden-exporters/src/models.rs
ctx: &mut KeyStoreContext<KeySlotIds>,
wrapping_key: SymmetricKeySlotId,
) -> Result<SymmetricKeySlotId, CryptoError> {
match &self.key {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hopefully can simplify with the follow-up of enabling cipher keys by default.

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs
Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
// new cipher key happens inside CompositeEncryptable at encrypt time.
#[allow(deprecated)]
let raw = ctx.dangerous_get_symmetric_key(new_key)?.clone();
self.key = Some(raw.to_base64().to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you can just put SymmetricKey instead of string on the FFI struct. It should convert fine, and we made the FFI constructs. That would save you a bunch of conversion.

let old_key = self.key_identifier();

// If any attachment is missing a key we can't reencrypt the attachment keys
if self.attachments.iter().flatten().any(|a| a.key.is_none()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably want to clean this up at some point, because this does not (in opposition to the naming) actually re-encrypt anything.

@shane-melton shane-melton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Partial review that I'll pick up tomorrow, but things are looking good!

#[deprecated(
note = "Use `get_fido2_credentials` instead - Fido2Credentials are no longer encrypted in `CipherView`"
)]
pub fn decrypt_fido2_credentials(&self) -> Vec<Fido2CredentialView> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🎨 I think we should be able to just remove this. We're the only consumers of it internally.

Comment thread crates/bitwarden-vault/src/cipher/cipher.rs Outdated
/// The raw private key material for this passkey credential.
/// Callers that receive `Fido2CredentialView` over a binding boundary should
/// treat this field with the same care as any private key material.
pub key_value: String,

@shane-melton shane-melton Aug 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

💭 Hmm, this is makes Fido2CredentialFullView obsolete. We should look at deprecating/removing that view. Maybe not in this PR if it increases the scope too much (IDE is showing around ~44 usages that would need to be updated)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good callout - agreed, let's remove it as a follow-up task!

Comment thread crates/bitwarden-vault/src/cipher/attachment.rs Outdated
@nikwithak

Copy link
Copy Markdown
Contributor Author

@quexten @shane-melton I believe I've addressed all the comments now - ready for another look!

@shane-melton shane-melton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work! I think we're pretty much there, only a few final comments/questions.

Ok(decrypted) => successes.push(decrypted),
Err(e) => {
tracing::warn!(attachment_id = ?attachment.id, error = %e, "Failed to decrypt attachment");
let recovered_key = attachment.key.as_ref().and_then(|attachment_key| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 Nice, we're still attempting to decrypt the key in case the filename fails above.

let cipher_key = Cipher::decrypt_cipher_key(ctx, wrapping_key, &view.key)?;
let blob = CipherBlobLatest::from_cipher_view(view, ctx, cipher_key)?;
seal_blob_content(blob, cipher_key, ctx)
let blob = CipherBlobLatest::from_cipher_view(view)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 Glad this is simplified even further and has one fewer level of key shuffling.

Comment thread crates/bitwarden-vault/src/cipher/blob/encryption.rs Outdated
fn seal_blob_content(
blob: CipherBlobLatest,
cipher_key: SymmetricKeySlotId,
wrapping_key: SymmetricKeySlotId,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

❓ Same here, this should always be a cipher key and renaming so could potentially help avoid confusion?

};

blob.apply_to_cipher_view(&mut view, ctx, cipher_key)?;
blob.apply_to_cipher_view(&mut view)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 So much nicer not to have to pass a context and key to what was supposed to be a simple mapper. 🎉

pub archived_date: Option<DateTime<Utc>>,
pub attachments: Vec<AttachmentView>,
pub key: Option<EncString>,
pub key: Option<String>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 I think this would be beneficial, ust like you were able to do for the AttachmentView.key


/// Temporary, required to support calculating TOTP from CipherListView.
pub key: Option<EncString>,
pub key: Option<String>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

❓ Could we just remove this? Or does it increase the scope? We never encrypt a CipherListView so it should never need the key.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good callout - since we no longer need it to decrypt other values, I think we can remove it entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

quexten
quexten previously approved these changes Aug 10, 2026

@quexten quexten left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One note: It may be beneficial to split the cipher-key out. Specifically, if the initial theory for the passkey corruption is correct, then this would currently - temporarily - make it worse until the KM side of the key-id tracking is done.

Previously, the encrypted cipher key would fail to decrypt with the wrong user key, and an update was not possible. Now, because the cipher-key is not encrypted at all, thee cipher would be encrypted with the cipher key present for the wrong user-key, so the corruption type would expand to all passkey assertions that happen with a wrong user-key in sdk, rather than just the subset of assertions with a wrong user-key for ciphers without a cipher-key.

Once the KM work is done, this will be prevented either way.

@shane-melton

Copy link
Copy Markdown
Member

Looks like the linter/clippy aren't happy

@shane-melton

shane-melton commented Aug 10, 2026

Copy link
Copy Markdown
Member

Still a few more unhappy CI jobs (if you're already on it, please ignore me, I was just watching the branch for my stacked PR)

@shane-melton shane-melton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚀

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.

4 participants