PM-41073: Consume breaking changes from SDK making all children of CipherView decrypted - #22245
PM-41073: Consume breaking changes from SDK making all children of CipherView decrypted#22245nikwithak wants to merge 5 commits into
Conversation
…pherView decrypted
🤖 Bitwarden Claude Code ReviewOverall Assessment: REQUEST CHANGES This revision consumes the breaking SDK change so Code Review Details
|
| */ | ||
| isLegacyAttachment(): boolean { | ||
| return this.key == null && this.encryptedKey == null; | ||
| return this.key == null; |
There was a problem hiding this comment.
Details and fix
CipherView.fromSdkCipherView builds views for attachmentDecryptionFailures with failure = true. Previously those views still carried encryptedKey (set from obj.key), so isLegacyAttachment() returned false. Now key == null for the same views, so isLegacyAttachment() — and therefore CipherView.hasOldAttachments — returns true for corrupt-key attachments.
Downstream effects for a user with one undecryptable attachment key:
- "Fix old attachments" prompt shows on the vault row (
vault-cipher-row.component.ts) - Key rotation is blocked (
key-rotation-dialog.service.ts:140) and the automatic V2 migration is skipped (v2-key-rotation-migration.ts:167) transferPersonalItemsthrows (default-vault-items-transfer.service.ts:234), becauseupgradeOldCipherAttachmentscan never clear the flag — the attachment key can't be unwrapped, so the download/re-encrypt step fails
Suggested fix:
isLegacyAttachment(): boolean {
return this.key == null && !this.hasDecryptionError;
}| @@ -97,7 +96,6 @@ export class CipherView implements View, InitializerMetadata { | |||
| this.archivedDate = c.archivedDate; | |||
| // Old locally stored ciphers might have reprompt == null. If so set it to None. | |||
| this.reprompt = c.reprompt ?? CipherRepromptType.None; | |||
There was a problem hiding this comment.
key copy leaves views from the legacy Cipher.decrypt() path without an item key, which is then lost on re-encryption.
Details and fix
Removing this.key = c.key is correct for the new SymmetricCryptoKey semantics (the wrapped key must not be assigned here), but nothing replaces it on the deprecated decrypt path:
Cipher.decrypt()(models/domain/cipher.ts:152) buildsnew CipherView(this)and unwraps the cipher key into a localcipherKey, but never assigns it to the view — soview.keyis now alwaysundefined.- That path is still live:
CipherService.decryptOrganizationCiphersResponse(cipher.service.ts:657, admin console org ciphers) and the admin attachment refresh invault-item-dialog.component.ts:553. - Saving such a view goes through
DefaultCipherEncryptionService.encrypt→toSdkCipherView()→key: undefined, so the SDK no longer receives the existing item key.
Suggested fix in Cipher.decrypt(), where the unwrapped key is already available:
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, userKeyOrOrgKey);
cipherDecryptionKey = cipherKey;
model.key = cipherKey;| this.key = | ||
| o.key instanceof SymmetricCryptoKey | ||
| ? o.key.toBase64() | ||
| : (o.key as EncString | undefined)?.encryptedString; |
There was a problem hiding this comment.
bw get item / bw list items output.
Details and fix
CipherResponse (apps/cli/src/vault/models/cipher.response.ts) extends CipherWithIdExport and calls build(cipherView), then serializes the whole object to stdout. Previously this.key was the wrapped key (EncString.encryptedString); with CipherView.key now a SymmetricCryptoKey, this emits the raw base64 item key — key material that can decrypt the item's attachments, written to terminals, shell history, and script logs.
The export services already guard against this (delete cipher.key in individual-vault-export.service.ts:354 and org-vault-export.service.ts:384); the CLI response has no equivalent.
Consider redacting key in CipherResponse's constructor, mirroring the existing viewPassword redaction.
| archivedDate: this.archivedDate?.toISOString(), | ||
| attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), | ||
| key: this.key?.toSdk(), | ||
| key: (this.key?.toBase64() ?? undefined) as SymmetricKey | undefined, |
There was a problem hiding this comment.
🎨 SUGGESTED: SymmetricCryptoKey.toSdk() already returns SymmetricKey, so the cast isn't needed here.
Details
key: this.key?.toSdk(),toSdk() is toBase64() as SymmetricKey, so this removes the manual assertion and keeps the type checked at the boundary. Same applies at cipher.view.ts:535 (which currently uses as any) and attachment.view.ts:64.
shane-melton
left a comment
There was a problem hiding this comment.
Partial review, but Claude has a few good points above.
Also, I think we need to update the LoginView as well now that Fido2Credentials no longer remain encrypted in the Views.
| view.favorite = req.favorite; | ||
| view.reprompt = req.reprompt ?? CipherRepromptType.None; | ||
| view.key = req.key != null ? new EncString(req.key) : undefined; | ||
| view.key = req.key != null ? SymmetricCryptoKey.fromString(req.key) : undefined; |
There was a problem hiding this comment.
EncString version of the key will now fail to import. (The EncString was removed in f167f06, but existed for exports before then).
We'll want to add some type of try/catch or format checking to avoid breaking the entire import just because an old key value is present.
| // Redact the decrypted item key to prevent logging it to the CLI output. | ||
| this.key = undefined; |
There was a problem hiding this comment.
key here makes bw edit item silently strip the cipher's item key.
Details and fix
The documented CLI edit flow round-trips bw get item output back through bw edit item. EditCommand passes the freshly decrypted view as the target of CipherExport.toView (apps/cli/src/commands/edit.command.ts:143), and toView overwrites key unconditionally:
view.key = req.key != null ? SymmetricCryptoKey.fromString(req.key) : undefined;Since req.key is now always absent, the item key already present on cipherView is discarded, and toSdkUpdateCipherRequest / toSdkCipherView then send key: undefined. Every CLI edit of a cipher that has an item key drops it. Previously CipherResponse.key carried the wrapped EncString, so the round trip preserved it.
The redaction itself is correct — the unconditional overwrite in toView is the problem. Consider only replacing the key when the request actually supplies one:
if (req.key != null) {
try {
view.key = SymmetricCryptoKey.fromString(req.key);
} catch {
// Old exports stored the wrapped EncString key, which cannot be used on import
}
}BitwardenJsonImporter already sets cipher.key = null immediately after toView, so import behavior is unaffected by the change.
| @@ -46,7 +46,6 @@ export class Attachment extends Domain { | |||
|
|
|||
| if (this.key != null) { | |||
| view.key = await this.decryptAttachmentKey(decryptionKey); | |||
There was a problem hiding this comment.
attachment.spec.ts still asserts the removed encryptedKey field, so this test fails.
Details and fix
libs/common/src/vault/models/domain/attachment.spec.ts:93-101 expects:
expect(view).toEqual({
...
key: expect.any(SymmetricCryptoKey),
encryptedKey: attachment.key,
});AttachmentView no longer declares encryptedKey and decrypt() no longer assigns it, so the actual view has no such property while the expectation is a defined EncString mock. toEqual only ignores properties that are undefined on both sides, so this assertion fails. Removing the encryptedKey line from the expectation resolves it.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-41073
📔 Objective
Consumes breaking SDK changes from bitwarden/sdk-internal#1353.
With these changes, a decrypted CipherView object no longer stores Fido2 creds, cipher keys, attachment keys, or TOTP secrets in a still-encrypted state.
Note: CI builds are failing because the SDK PR hasn't merged yet.