1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-16098] Improved cipher decryption error handling (#12468)

* [PM-16098] Add decryptionFailure flag to CipherView

* [PM-16098] Add failedToDecryptCiphers$ observable to CipherService

* [PM-16098] Introduce decryption-failure-dialog.component

* [PM-16098] Disable cipher rows for the Web Vault

* [PM-16098] Show decryption error dialog on vault load or when attempting to view/edit a corrupted cipher

* [PM-16098] Browser - Show decryption error dialog on vault load or when attempting to view/edit a corrupted cipher

* [PM-16098] Desktop - Show decryption error dialog on vault load or when attempting to view a corrupted cipher. Remove edit/clone context menu options and footer actions.

* [PM-16098] Add CS link to decryption failure dialog

* [PM-16098] Return cipherViews and move filtering of isDeleted to consumers

* [PM-16098] Throw an error when retrieving cipher data for key rotation when a decryption failure is present

* [PM-16098] Properly filter out deleted, corrupted ciphers when showing dialog within the Vault

* [PM-16098] Show the decryption error dialog when attempting to view a cipher in trash and disable the restore option

* [PM-16098] Exclude failed to decrypt ciphers from getAllDecrypted method and cipherViews$ observable

* [PM-16098] Avoid re-sorting remainingCiphers$ as it was redundant

* [PM-16098] Update tests

* [PM-16098] Prevent opening view dialog in AC for corrupted ciphers

* [PM-16098] Remove withLatestFrom operator that was causing race conditions when navigating away from the individual vault

* [PM-16098] Ensure decryption error dialog is only shown once on Desktop when switching accounts
This commit is contained in:
Shane Melton
2025-01-08 08:42:46 -08:00
committed by GitHub
parent 65a27e7bfd
commit d72dd2ea76
29 changed files with 467 additions and 74 deletions

View File

@@ -2804,6 +2804,20 @@
"error": {
"message": "Error"
},
"decryptionError": {
"message": "Decryption error"
},
"couldNotDecryptVaultItemsBelow": {
"message": "Bitwarden could not decrypt the vault item(s) listed below."
},
"contactCSToAvoidDataLossPart1": {
"message": "Contact customer success",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"contactCSToAvoidDataLossPart2": {
"message": "to avoid additional data loss.",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"generateUsername": {
"message": "Generate username"
},

View File

@@ -5,6 +5,7 @@
size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name"
[disabled]="cipher.decryptionFailure"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>

View File

@@ -18,19 +18,25 @@ import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
ButtonModule,
CompactModeService,
DialogService,
IconButtonModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
import { OrgIconDirective, PasswordRepromptService } from "@bitwarden/vault";
import {
DecryptionFailureDialogComponent,
OrgIconDirective,
PasswordRepromptService,
} from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
@@ -55,6 +61,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
ItemMoreOptionsComponent,
OrgIconDirective,
ScrollingModule,
DecryptionFailureDialogComponent,
],
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",
@@ -158,6 +165,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
private cipherService: CipherService,
private router: Router,
private platformUtilsService: PlatformUtilsService,
private dialogService: DialogService,
) {}
async ngAfterViewInit() {
@@ -209,6 +217,13 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
this.viewCipherTimeout = window.setTimeout(
async () => {
try {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return;
}
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;

View File

@@ -1,15 +1,17 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component, DestroyRef, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router";
import { combineLatest, Observable, shareReplay, switchMap } from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
import { VaultIcons } from "@bitwarden/vault";
import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
@@ -52,6 +54,7 @@ enum VaultState {
NewItemDropdownV2Component,
ScrollingModule,
VaultHeaderV2Component,
DecryptionFailureDialogComponent,
],
providers: [VaultUiOnboardingService],
})
@@ -89,6 +92,9 @@ export class VaultV2Component implements OnInit, OnDestroy {
private vaultPopupItemsService: VaultPopupItemsService,
private vaultPopupListFiltersService: VaultPopupListFiltersService,
private vaultUiOnboardingService: VaultUiOnboardingService,
private destroyRef: DestroyRef,
private cipherService: CipherService,
private dialogService: DialogService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
@@ -116,6 +122,19 @@ export class VaultV2Component implements OnInit, OnDestroy {
async ngOnInit() {
await this.vaultUiOnboardingService.showOnboardingDialog();
this.cipherService.failedToDecryptCiphers$
.pipe(
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
filter((ciphers) => ciphers.length > 0),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((ciphers) => {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: ciphers.map((c) => c.id as CipherId),
});
});
}
ngOnDestroy(): void {}

View File

@@ -58,6 +58,7 @@ describe("VaultPopupItemsService", () => {
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
cipherServiceMock.ciphers$ = new BehaviorSubject(null);
cipherServiceMock.localData$ = new BehaviorSubject(null);
cipherServiceMock.failedToDecryptCiphers$ = new BehaviorSubject([]);
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(c.id)),
@@ -294,21 +295,6 @@ describe("VaultPopupItemsService", () => {
});
});
it("should sort by last used then by name by default", (done) => {
service.remainingCiphers$.subscribe(() => {
expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled();
done();
});
});
it("should NOT sort by last used then by name when search text is applied", (done) => {
service.applyFilter("Login");
service.remainingCiphers$.subscribe(() => {
expect(cipherServiceMock.getLocaleSortingFunction).not.toHaveBeenCalled();
done();
});
});
it("should filter remainingCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login";

View File

@@ -90,6 +90,8 @@ export class VaultPopupItemsService {
tap(() => this._ciphersLoading$.next()),
waitUntilSync(this.syncService),
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
withLatestFrom(this.cipherService.failedToDecryptCiphers$),
map(([ciphers, failedToDecryptCiphers]) => [...failedToDecryptCiphers, ...ciphers]),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -190,11 +192,6 @@ export class VaultPopupItemsService {
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
),
),
withLatestFrom(this._hasSearchText$),
map(([ciphers, hasSearchText]) =>
// Do not sort alphabetically when there is search text, default to the search service scoring
hasSearchText ? ciphers : ciphers.sort(this.cipherService.getLocaleSortingFunction()),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);

View File

@@ -27,7 +27,12 @@
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<button type="button" bitMenuItem (click)="restore(cipher)">
<button
type="button"
bitMenuItem
(click)="restore(cipher)"
*ngIf="!cipher.decryptionFailure"
>
{{ "restore" | i18n }}
</button>
<button type="button" bitMenuItem *appCanDeleteCipher="cipher" (click)="delete(cipher)">

View File

@@ -7,6 +7,7 @@ import { Router } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
@@ -19,7 +20,11 @@ import {
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/vault";
import {
CanDeleteCipherDirective,
DecryptionFailureDialogComponent,
PasswordRepromptService,
} from "@bitwarden/vault";
@Component({
selector: "app-trash-list-items-container",
@@ -35,6 +40,7 @@ import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/va
MenuModule,
IconButtonModule,
TypographyModule,
DecryptionFailureDialogComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -105,6 +111,13 @@ export class TrashListItemsContainerComponent {
}
async onViewCipher(cipher: CipherView) {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return;
}
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;

View File

@@ -7,7 +7,8 @@ import { NgModule } from "@angular/core";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { DialogModule, CalloutModule } from "@bitwarden/components";
import { CalloutModule, DialogModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { DeleteAccountComponent } from "../auth/delete-account.component";
@@ -61,6 +62,7 @@ import { SendComponent } from "./tools/send/send.component";
CalloutModule,
DeleteAccountComponent,
UserVerificationComponent,
DecryptionFailureDialogComponent,
],
declarations: [
AccessibilityCookieComponent,

View File

@@ -249,6 +249,20 @@
"error": {
"message": "Error"
},
"decryptionError": {
"message": "Decryption error"
},
"couldNotDecryptVaultItemsBelow": {
"message": "Bitwarden could not decrypt the vault item(s) listed below."
},
"contactCSToAvoidDataLossPart1": {
"message": "Contact customer success",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"contactCSToAvoidDataLossPart2": {
"message": "to avoid additional data loss.",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"january": {
"message": "January"
},

View File

@@ -10,8 +10,8 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil, switchMap } from "rxjs";
import { first } from "rxjs/operators";
import { combineLatest, firstValueFrom, Subject, takeUntil, switchMap } from "rxjs";
import { filter, first, map, take } from "rxjs/operators";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -28,13 +28,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DecryptionFailureDialogComponent, PasswordRepromptService } from "@bitwarden/vault";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { GeneratorComponent } from "../../../app/tools/generator.component";
@@ -113,6 +115,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private configService: ConfigService,
private accountService: AccountService,
private cipherService: CipherService,
) {}
async ngOnInit() {
@@ -238,6 +241,25 @@ export class VaultComponent implements OnInit, OnDestroy {
notificationId: authRequest.id,
});
}
// Store a reference to the current active account during page init
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// Combine with the activeAccount$ to ensure we only show the dialog for the current account from ngOnInit.
// The account switching process updates the cipherService before Vault is destroyed and would cause duplicate emissions
combineLatest([this.accountService.activeAccount$, this.cipherService.failedToDecryptCiphers$])
.pipe(
filter(([account]) => account.id === activeAccount.id),
map(([_, ciphers]) => ciphers.filter((c) => !c.isDeleted)),
filter((ciphers) => ciphers.length > 0),
take(1),
takeUntil(this.componentIsDestroyed$),
)
.subscribe((ciphers) => {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: ciphers.map((c) => c.id as CipherId),
});
});
}
ngOnDestroy() {
@@ -302,6 +324,12 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
},
];
if (cipher.decryptionFailure) {
invokeMenu(menu);
return;
}
if (!cipher.isDeleted) {
menu.push({
label: this.i18nService.t("edit"),

View File

@@ -638,33 +638,35 @@
</div>
</div>
<div class="footer" *ngIf="cipher">
<button
type="button"
class="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
*ngIf="!cipher.isDeleted"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
*ngIf="cipher.isDeleted"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="!cipher?.organizationId && !cipher.isDeleted"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<ng-container *ngIf="!cipher.decryptionFailure">
<button
type="button"
class="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
*ngIf="!cipher.isDeleted"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
*ngIf="cipher.isDeleted"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="!cipher?.organizationId && !cipher.isDeleted"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
</ng-container>
<div class="right" *ngIf="canDeleteCipher$ | async">
<button
type="button"

View File

@@ -25,6 +25,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
@@ -32,7 +33,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
import { DecryptionFailureDialogComponent, PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "ViewComponent";
@@ -98,6 +99,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
}
ngOnInit() {
super.ngOnInit();
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
@@ -117,6 +119,13 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
async ngOnChanges() {
await super.load();
if (this.cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [this.cipherId as CipherId],
});
return;
}
}
viewHistory() {

View File

@@ -40,6 +40,7 @@ import {
CipherFormGenerationService,
CipherFormModule,
CipherViewComponent,
DecryptionFailureDialogComponent,
} from "@bitwarden/vault";
import { SharedModule } from "../../../shared/shared.module";
@@ -114,6 +115,7 @@ export enum VaultItemDialogResult {
CipherAttachmentsComponent,
AsyncActionsModule,
ItemModule,
DecryptionFailureDialogComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
@@ -252,6 +254,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.cipher = await this.getDecryptedCipherView(this.formConfig);
if (this.cipher) {
if (this.cipher.decryptionFailure) {
this.dialogService.open(DecryptionFailureDialogComponent, {
data: { cipherIds: [this.cipher.id] },
});
this.dialogRef.close();
return;
}
this.collections = this.formConfig.collections.filter((c) =>
this.cipher.collectionIds?.includes(c.id),
);

View File

@@ -4,7 +4,7 @@
type="checkbox"
bitCheckbox
appStopProp
[disabled]="disabled"
[disabled]="disabled || cipher.decryptionFailure"
[checked]="checked"
(change)="$event ? this.checkedToggled.next() : null"
[attr.aria-label]="'vaultItemSelect' | i18n"
@@ -20,7 +20,7 @@
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
[disabled]="disabled"
[routerLink]="[]"
[queryParams]="{ itemId: cipher.id, action: extensionRefreshEnabled ? 'view' : null }"
[queryParams]="{ itemId: cipher.id, action: clickAction }"
queryParamsHandling="merge"
[replaceUrl]="extensionRefreshEnabled"
title="{{ 'editItemWithName' | i18n: cipher.name }}"
@@ -76,6 +76,25 @@
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button
*ngIf="cipher.decryptionFailure"
[disabled]="disabled || !canManageCollection"
[bitMenuTriggerFor]="corruptedCipherOptions"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
appA11yTitle="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #corruptedCipherOptions>
<button bitMenuItem *ngIf="canManageCollection" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
</bit-menu>
<button
*ngIf="!cipher.decryptionFailure"
[disabled]="disabled || disableMenu"
[bitMenuTriggerFor]="cipherOptions"
size="small"

View File

@@ -78,6 +78,13 @@ export class VaultCipherRowComponent implements OnInit {
}
}
protected get clickAction() {
if (this.cipher.decryptionFailure) {
return "showFailedToDecrypt";
}
return this.extensionRefreshEnabled ? "view" : null;
}
protected get showTotpCopyButton() {
return (
(this.cipher.login?.hasTotp ?? false) &&

View File

@@ -29,6 +29,7 @@ import {
map,
shareReplay,
switchMap,
take,
takeUntil,
tap,
} from "rxjs/operators";
@@ -75,6 +76,7 @@ import { DialogService, Icons, ToastService } from "@bitwarden/components";
import {
CipherFormConfig,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
DefaultCipherFormConfigService,
PasswordRepromptService,
} from "@bitwarden/vault";
@@ -144,6 +146,7 @@ const SearchTextDebounceInterval = 200;
VaultFilterModule,
VaultItemsModule,
SharedModule,
DecryptionFailureDialogComponent,
],
providers: [
RoutedVaultFilterService,
@@ -359,13 +362,16 @@ export class VaultComponent implements OnInit, OnDestroy {
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$);
const filterFunction = createFilterFunction(filter);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];
if (await this.searchService.isSearchable(searchText)) {
return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers);
return await this.searchService.searchCiphers(searchText, [filterFunction], allCiphers);
}
return ciphers.filter(filterFunction);
return allCiphers.filter(filterFunction);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -436,6 +442,18 @@ export class VaultComponent implements OnInit, OnDestroy {
action = "view";
}
if (action == "showFailedToDecrypt") {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipherId as CipherId],
});
await this.router.navigate([], {
queryParams: { itemId: null, cipherId: null, action: null },
queryParamsHandling: "merge",
replaceUrl: true,
});
return;
}
if (action === "view") {
await this.viewCipherById(cipherId);
} else {
@@ -458,6 +476,20 @@ export class VaultComponent implements OnInit, OnDestroy {
)
.subscribe();
firstSetup$
.pipe(
switchMap(() => this.cipherService.failedToDecryptCiphers$),
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
filter((ciphers) => ciphers.length > 0),
take(1),
takeUntil(this.destroy$),
)
.subscribe((ciphers) => {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: ciphers.map((c) => c.id as CipherId),
});
});
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
firstSetup$

View File

@@ -38,9 +38,9 @@ import {
import {
CollectionAdminService,
CollectionAdminView,
Unassigned,
CollectionService,
CollectionView,
Unassigned,
} from "@bitwarden/admin-console/common";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -71,16 +71,17 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import {
BannerModule,
DialogService,
Icons,
NoItemsModule,
ToastService,
BannerModule,
} from "@bitwarden/components";
import {
CipherFormConfig,
CipherFormConfigService,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
PasswordRepromptService,
} from "@bitwarden/vault";
@@ -134,6 +135,7 @@ import {
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { AdminConsoleCipherFormConfigService } from "./services/admin-console-cipher-form-config.service";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
const BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200;
@@ -549,11 +551,24 @@ export class VaultComponent implements OnInit, OnDestroy {
if (cipher) {
let action = qParams.action;
// Default to "view" if extension refresh is enabled
if (action == null && this.extensionRefreshEnabled) {
action = "view";
}
if (action == "showFailedToDecrypt") {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipherId as CipherId],
});
await this.router.navigate([], {
queryParams: { itemId: null, cipherId: null, action: null },
queryParamsHandling: "merge",
replaceUrl: true,
});
return;
}
if (action === "view") {
await this.viewCipherById(cipher);
} else {

View File

@@ -5676,6 +5676,20 @@
"error": {
"message": "Error"
},
"decryptionError": {
"message": "Decryption error"
},
"couldNotDecryptVaultItemsBelow": {
"message": "Bitwarden could not decrypt the vault item(s) listed below."
},
"contactCSToAvoidDataLossPart1": {
"message": "Contact customer success",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"contactCSToAvoidDataLossPart2": {
"message": "to avoid additional data loss.",
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
},
"accountRecoveryManageUsers": {
"message": "Manage users must also be granted with the manage account recovery permission"
},

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, Subject, from, switchMap, takeUntil } from "rxjs";
import { BehaviorSubject, firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -118,6 +118,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected async doSearch(indexedCiphers?: CipherView[]) {
indexedCiphers = indexedCiphers ?? (await this.cipherService.getAllDecrypted());
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$);
if (failedCiphers != null && failedCiphers.length > 0) {
indexedCiphers = [...failedCiphers, ...indexedCiphers];
}
this.ciphers = await this.searchService.searchCiphers(
this.searchText,
[this.filter, this.deletedFilter],

View File

@@ -26,6 +26,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* An observable monitoring the add/edit cipher info saved to memory.
*/
addEditCipherInfo$: Observable<AddEditCipherInfo>;
/**
* Observable that emits an array of cipherViews that failed to decrypt. Does not emit until decryption has completed.
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$: Observable<CipherView[]>;
clearCache: (userId?: string) => Promise<void>;
encrypt: (
model: CipherView,

View File

@@ -136,7 +136,13 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
encKey = new SymmetricCryptoKey(await encryptService.decryptToBytes(this.key, encKey));
const keyBytes = await encryptService.decryptToBytes(this.key, encKey);
if (keyBytes == null) {
model.name = "[error: cannot decrypt]";
model.decryptionFailure = true;
return model;
}
encKey = new SymmetricCryptoKey(keyBytes);
bypassValidation = false;
}

View File

@@ -46,6 +46,11 @@ export class CipherView implements View, InitializerMetadata {
deletedDate: Date = null;
reprompt: CipherRepromptType = CipherRepromptType.None;
/**
* Flag to indicate if the cipher decryption failed.
*/
decryptionFailure = false;
constructor(c?: Cipher) {
if (!c) {
return;

View File

@@ -359,6 +359,7 @@ describe("Cipher Service", () => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
let failedCiphers: BehaviorSubject<CipherView[]>;
let encryptedKey: EncString;
beforeEach(() => {
@@ -385,6 +386,7 @@ describe("Cipher Service", () => {
Cipher2: cipher2,
});
cipherService.cipherViews$ = decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers)));
cipherService.failedToDecryptCiphers$ = failedCiphers = new BehaviorSubject<CipherView[]>([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
encryptedKey = new EncString("Re-encrypted Cipher Key");
@@ -413,5 +415,16 @@ describe("Cipher Service", () => {
"New user key is required to rotate ciphers",
);
});
it("throws if the user has any failed to decrypt ciphers", async () => {
const badCipher = new CipherView(cipherObj);
badCipher.id = "Cipher 3";
badCipher.organizationId = null;
badCipher.decryptionFailure = true;
failedCiphers.next([badCipher]);
await expect(
cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId),
).rejects.toThrow("Cannot rotate ciphers when decryption failures are present");
});
});
});

View File

@@ -7,6 +7,7 @@ import {
map,
merge,
Observable,
of,
shareReplay,
Subject,
switchMap,
@@ -79,6 +80,7 @@ import {
ADD_EDIT_CIPHER_INFO_KEY,
DECRYPTED_CIPHERS,
ENCRYPTED_CIPHERS,
FAILED_DECRYPTED_CIPHERS,
LOCAL_DATA_KEY,
} from "./key-state/ciphers.state";
@@ -109,9 +111,17 @@ export class CipherService implements CipherServiceAbstraction {
cipherViews$: Observable<CipherView[] | null>;
addEditCipherInfo$: Observable<AddEditCipherInfo>;
/**
* Observable that emits an array of cipherViews that failed to decrypt. Does not emit until decryption has completed.
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
failedToDecryptCiphers$: Observable<CipherView[]>;
private localDataState: ActiveUserState<Record<CipherId, LocalData>>;
private encryptedCiphersState: ActiveUserState<Record<CipherId, CipherData>>;
private decryptedCiphersState: ActiveUserState<Record<CipherId, CipherView>>;
private failedToDecryptCiphersState: ActiveUserState<CipherView[]>;
private addEditCipherInfoState: ActiveUserState<AddEditCipherInfo>;
constructor(
@@ -132,6 +142,7 @@ export class CipherService implements CipherServiceAbstraction {
this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY);
this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS);
this.decryptedCiphersState = this.stateProvider.getActive(DECRYPTED_CIPHERS);
this.failedToDecryptCiphersState = this.stateProvider.getActive(FAILED_DECRYPTED_CIPHERS);
this.addEditCipherInfoState = this.stateProvider.getActive(ADD_EDIT_CIPHER_INFO_KEY);
this.localData$ = this.localDataState.state$.pipe(map((data) => data ?? {}));
@@ -143,6 +154,13 @@ export class CipherService implements CipherServiceAbstraction {
switchMap(() => merge(this.forceCipherViews$, this.getAllDecrypted())),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.failedToDecryptCiphers$ = this.failedToDecryptCiphersState.state$.pipe(
filter((ciphers) => ciphers != null),
switchMap((ciphers) => merge(this.forceCipherViews$, of(ciphers))),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.addEditCipherInfo$ = this.addEditCipherInfoState.state$;
}
@@ -162,6 +180,10 @@ export class CipherService implements CipherServiceAbstraction {
}
}
async setFailedDecryptedCiphers(cipherViews: CipherView[], userId: UserId) {
await this.stateProvider.setUserState(FAILED_DECRYPTED_CIPHERS, cipherViews, userId);
}
private async setDecryptedCiphers(value: CipherView[], userId: UserId) {
const cipherViews: { [id: string]: CipherView } = {};
value?.forEach((c) => {
@@ -378,7 +400,7 @@ export class CipherService implements CipherServiceAbstraction {
*/
@sequentialize(() => "getAllDecrypted")
async getAllDecrypted(): Promise<CipherView[]> {
let decCiphers = await this.getDecryptedCiphers();
const decCiphers = await this.getDecryptedCiphers();
if (decCiphers != null && decCiphers.length !== 0) {
await this.reindexCiphers();
return await this.getDecryptedCiphers();
@@ -390,10 +412,15 @@ export class CipherService implements CipherServiceAbstraction {
return [];
}
decCiphers = await this.decryptCiphers(await this.getAll(), activeUserId);
const [newDecCiphers, failedCiphers] = await this.decryptCiphers(
await this.getAll(),
activeUserId,
);
await this.setDecryptedCipherCache(decCiphers, activeUserId);
return decCiphers;
await this.setDecryptedCipherCache(newDecCiphers, activeUserId);
await this.setFailedDecryptedCiphers(failedCiphers, activeUserId);
return newDecCiphers;
}
private async getDecryptedCiphers() {
@@ -402,7 +429,17 @@ export class CipherService implements CipherServiceAbstraction {
);
}
private async decryptCiphers(ciphers: Cipher[], userId: UserId) {
/**
* Decrypts the provided ciphers using the provided user's keys.
* @param ciphers
* @param userId
* @returns Two cipher arrays, the first containing successfully decrypted ciphers and the second containing ciphers that failed to decrypt.
* @private
*/
private async decryptCiphers(
ciphers: Cipher[],
userId: UserId,
): Promise<[CipherView[], CipherView[]]> {
const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true));
if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) {
@@ -420,7 +457,7 @@ export class CipherService implements CipherServiceAbstraction {
{} as Record<string, Cipher[]>,
);
const decCiphers = (
const allCipherViews = (
await Promise.all(
Object.entries(grouped).map(async ([orgId, groupedCiphers]) => {
if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) {
@@ -440,7 +477,18 @@ export class CipherService implements CipherServiceAbstraction {
.flat()
.sort(this.getLocaleSortingFunction());
return decCiphers;
// Split ciphers into two arrays, one for successfully decrypted ciphers and one for ciphers that failed to decrypt
return allCipherViews.reduce(
(acc, c) => {
if (c.decryptionFailure) {
acc[1].push(c);
} else {
acc[0].push(c);
}
return acc;
},
[[], []] as [CipherView[], CipherView[]],
);
}
private async reindexCiphers() {
@@ -1272,10 +1320,15 @@ export class CipherService implements CipherServiceAbstraction {
let encryptedCiphers: CipherWithIdRequest[] = [];
const ciphers = await firstValueFrom(this.cipherViews$);
const failedCiphers = await firstValueFrom(this.failedToDecryptCiphers$);
if (!ciphers) {
return encryptedCiphers;
}
if (failedCiphers.length > 0) {
throw new Error("Cannot rotate ciphers when decryption failures are present");
}
const userCiphers = ciphers.filter((c) => c.organizationId == null);
if (userCiphers.length === 0) {
return encryptedCiphers;
@@ -1636,6 +1689,7 @@ export class CipherService implements CipherServiceAbstraction {
private async clearDecryptedCiphersState(userId: UserId) {
await this.setDecryptedCiphers(null, userId);
await this.setFailedDecryptedCiphers(null, userId);
this.clearSortedCiphers();
}

View File

@@ -28,6 +28,15 @@ export const DECRYPTED_CIPHERS = UserKeyDefinition.record<CipherView>(
},
);
export const FAILED_DECRYPTED_CIPHERS = UserKeyDefinition.array<CipherView>(
CIPHERS_MEMORY,
"failedDecryptedCiphers",
{
deserializer: (cipher: Jsonify<CipherView>) => CipherView.fromJSON(cipher),
clearOn: ["logout", "lock"],
},
);
export const LOCAL_DATA_KEY = new UserKeyDefinition<Record<CipherId, LocalData>>(
CIPHERS_DISK_LOCAL,
"localData",

View File

@@ -0,0 +1,32 @@
<bit-simple-dialog>
<i
bitDialogIcon
class="bwi tw-text-3xl bwi-exclamation-triangle tw-text-warning"
aria-hidden="true"
></i>
<span bitDialogTitle>{{ "decryptionError" | i18n }}</span>
<div bitDialogContent>
<p>
{{ "couldNotDecryptVaultItemsBelow" | i18n }}
<a bitLink href="#" (click)="openContactSupport($event)">{{
"contactCSToAvoidDataLossPart1" | i18n
}}</a>
{{ "contactCSToAvoidDataLossPart2" | i18n }}
</p>
<ul class="tw-list-none tw-pl-0">
<li
*ngFor="let id of params.cipherIds"
class="tw-text-code tw-font-mono tw-py-0.5"
(click)="selectText(listItem)"
#listItem
>
{{ id }}
</li>
</ul>
</div>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close(false)">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

@@ -0,0 +1,59 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId } from "@bitwarden/common/types/guid";
import {
AnchorLinkDirective,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
TypographyModule,
} from "@bitwarden/components";
export type DecryptionFailureDialogParams = {
cipherIds: CipherId[];
};
@Component({
standalone: true,
selector: "vault-decryption-failure-dialog",
templateUrl: "./decryption-failure-dialog.component.html",
imports: [
DialogModule,
CommonModule,
TypographyModule,
JslibModule,
AsyncActionsModule,
ButtonModule,
AnchorLinkDirective,
],
})
export class DecryptionFailureDialogComponent {
protected dialogRef = inject(DialogRef);
protected params = inject<DecryptionFailureDialogParams>(DIALOG_DATA);
protected platformUtilsService = inject(PlatformUtilsService);
selectText(element: HTMLElement) {
const selection = window.getSelection();
if (selection == null) {
return;
}
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(element);
selection.addRange(range);
}
openContactSupport(event: Event) {
event.preventDefault();
this.platformUtilsService.launchUri("https://bitwarden.com/contact");
}
static open(dialogService: DialogService, params: DecryptionFailureDialogParams) {
return dialogService.open(DecryptionFailureDialogComponent, { data: params });
}
}

View File

@@ -16,5 +16,6 @@ export { DownloadAttachmentComponent } from "./components/download-attachment/do
export { PasswordHistoryViewComponent } from "./components/password-history-view/password-history-view.component";
export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component";
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
export * as VaultIcons from "./icons";