From 3af19ad9340deffdcbf6df943d8b9fddf907c60c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 10 Dec 2025 04:03:31 +0100 Subject: [PATCH] [PM-28813] Implement encryption diagnostics & recovery tool (#17673) * Implement data recovery tool * Fix tests * Move Sdkloadservice call and use bit action --- .../data-recovery.component.html | 75 ++++ .../data-recovery.component.spec.ts | 348 ++++++++++++++++++ .../data-recovery/data-recovery.component.ts | 208 +++++++++++ .../data-recovery/log-recorder.ts | 19 + .../data-recovery/steps/cipher-step.ts | 81 ++++ .../data-recovery/steps/folder-step.ts | 97 +++++ .../data-recovery/steps/index.ts | 6 + .../data-recovery/steps/private-key-step.ts | 93 +++++ .../data-recovery/steps/recovery-step.ts | 43 +++ .../data-recovery/steps/sync-step.ts | 43 +++ .../data-recovery/steps/user-info-step.ts | 49 +++ apps/web/src/app/oss-routing.module.ts | 7 + apps/web/src/locales/en/messages.json | 48 +++ libs/common/src/enums/feature-flag.enum.ts | 2 + ...ser-asymmetric-key-regeneration.service.ts | 7 + ...symmetric-key-regeneration.service.spec.ts | 49 +++ ...ser-asymmetric-key-regeneration.service.ts | 7 +- 17 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.html create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.ts create mode 100644 apps/web/src/app/key-management/data-recovery/log-recorder.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/folder-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/index.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/sync-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.html b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html new file mode 100644 index 00000000000..f357e516115 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html @@ -0,0 +1,75 @@ +

{{ "dataRecoveryTitle" | i18n }}

+ +
+

+ {{ "dataRecoveryDescription" | i18n }} +

+ + @if (!diagnosticsCompleted() && !recoveryCompleted()) { + + } + +
+ @for (step of steps(); track $index) { + @if ( + ($index === 0 && hasStarted()) || + ($index > 0 && + (steps()[$index - 1].status === StepStatus.Completed || + steps()[$index - 1].status === StepStatus.Failed)) + ) { +
+
+ @if (step.status === StepStatus.Failed) { + + } @else if (step.status === StepStatus.Completed) { + + } @else if (step.status === StepStatus.InProgress) { + + } @else { + + } +
+
+ + {{ step.title }} + +
+
+ } + } +
+ + @if (diagnosticsCompleted()) { +
+ @if (hasIssues() && !recoveryCompleted()) { + + } + +
+ } +
diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts new file mode 100644 index 00000000000..1976a8dfe27 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts @@ -0,0 +1,348 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { DataRecoveryComponent, StepStatus } from "./data-recovery.component"; +import { RecoveryStep, RecoveryWorkingData } from "./steps"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +describe("DataRecoveryComponent", () => { + let component: DataRecoveryComponent; + let fixture: ComponentFixture; + + // Mock Services + let mockI18nService: MockProxy; + let mockApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockKeyService: MockProxy; + let mockFolderApiService: MockProxy; + let mockCipherEncryptService: MockProxy; + let mockDialogService: MockProxy; + let mockPrivateKeyRegenerationService: MockProxy; + let mockLogService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockFileDownloadService: MockProxy; + + const mockUserId = "user-id" as UserId; + + beforeEach(async () => { + mockI18nService = mock(); + mockApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockKeyService = mock(); + mockFolderApiService = mock(); + mockCipherEncryptService = mock(); + mockDialogService = mock(); + mockPrivateKeyRegenerationService = mock(); + mockLogService = mock(); + mockCryptoFunctionService = mock(); + mockFileDownloadService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`); + + await TestBed.configureTestingModule({ + imports: [DataRecoveryComponent], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: FolderApiServiceAbstraction, useValue: mockFolderApiService }, + { provide: CipherEncryptionService, useValue: mockCipherEncryptService }, + { provide: DialogService, useValue: mockDialogService }, + { + provide: UserAsymmetricKeysRegenerationService, + useValue: mockPrivateKeyRegenerationService, + }, + { provide: LogService, useValue: mockLogService }, + { provide: CryptoFunctionService, useValue: mockCryptoFunctionService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DataRecoveryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("Component Initialization", () => { + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default signal values", () => { + expect(component.status()).toBe(StepStatus.NotStarted); + expect(component.hasStarted()).toBe(false); + expect(component.diagnosticsCompleted()).toBe(false); + expect(component.recoveryCompleted()).toBe(false); + expect(component.hasIssues()).toBe(false); + }); + + it("should initialize steps in correct order", () => { + const steps = component.steps(); + expect(steps.length).toBe(5); + expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n"); + expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n"); + expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n"); + expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n"); + expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n"); + }); + }); + + describe("runDiagnostics", () => { + let mockSteps: MockProxy[]; + + beforeEach(() => { + // Create mock steps + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.runDiagnostics.mockResolvedValue(true); + mockStep.canRecover.mockReturnValue(false); + return mockStep; + }); + + // Replace recovery steps with mocks + component["recoverySteps"] = mockSteps; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runDiagnostics(); + + expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled(); + }); + + it("should set hasStarted, isRunning and initialize workingData", async () => { + await component.runDiagnostics(); + + expect(component.hasStarted()).toBe(true); + expect(component["workingData"]).toBeDefined(); + expect(component["workingData"]?.userId).toBeNull(); + expect(component["workingData"]?.userKey).toBeNull(); + }); + + it("should run diagnostics for all steps", async () => { + await component.runDiagnostics(); + + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith( + component["workingData"], + expect.anything(), + ); + }); + }); + + it("should mark steps as completed when diagnostics succeed", async () => { + await component.runDiagnostics(); + + const steps = component.steps(); + steps.forEach((step) => { + expect(step.status).toBe(StepStatus.Completed); + }); + }); + + it("should mark steps as failed when diagnostics return false", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[2].status).toBe(StepStatus.Failed); + }); + + it("should mark steps as failed when diagnostics throw error", async () => { + mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error")); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[3].status).toBe(StepStatus.Failed); + expect(steps[3].message).toBe("Test error"); + }); + + it("should continue diagnostics even if a step fails", async () => { + mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed")); + mockSteps[3].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + // All steps should have been called despite failures + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalled(); + }); + }); + + it("should set hasIssues to true when a step can recover", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + mockSteps[2].canRecover.mockReturnValue(true); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(true); + }); + + it("should set hasIssues to false when no step can recover", async () => { + mockSteps.forEach((step) => { + step.runDiagnostics.mockResolvedValue(true); + step.canRecover.mockReturnValue(false); + }); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(false); + }); + + it("should set diagnosticsCompleted and status to completed when complete", async () => { + await component.runDiagnostics(); + + expect(component.diagnosticsCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + }); + + describe("runRecovery", () => { + let mockSteps: MockProxy[]; + let mockWorkingData: RecoveryWorkingData; + + beforeEach(() => { + mockWorkingData = { + userId: mockUserId, + userKey: null as any, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.canRecover.mockReturnValue(false); + mockStep.runRecovery.mockResolvedValue(); + mockStep.runDiagnostics.mockResolvedValue(true); + return mockStep; + }); + + component["recoverySteps"] = mockSteps; + component["workingData"] = mockWorkingData; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should not run if workingData is null", async () => { + component["workingData"] = null; + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should only run recovery for steps that can recover", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[3].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[1].runRecovery).toHaveBeenCalled(); + expect(mockSteps[2].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[3].runRecovery).toHaveBeenCalled(); + expect(mockSteps[4].runRecovery).not.toHaveBeenCalled(); + }); + + it("should set recoveryCompleted and status when successful", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(component.recoveryCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + + it("should set status to failed if recovery is cancelled", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled")); + + await component.runRecovery(); + + expect(component.status()).toBe(StepStatus.Failed); + expect(component.recoveryCompleted()).toBe(false); + }); + + it("should re-run diagnostics after recovery completes", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + // Diagnostics should be called twice: once for initial diagnostic scan + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything()); + }); + }); + + it("should update hasIssues after re-running diagnostics", async () => { + // Setup initial state with an issue + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runDiagnostics.mockResolvedValue(false); + + // After recovery completes, the issue should be fixed + mockSteps[1].runRecovery.mockImplementation(() => { + // Simulate recovery fixing the issue + mockSteps[1].canRecover.mockReturnValue(false); + mockSteps[1].runDiagnostics.mockResolvedValue(true); + return Promise.resolve(); + }); + + await component.runRecovery(); + + // Verify hasIssues is updated after re-running diagnostics + expect(component.hasIssues()).toBe(false); + }); + }); + + describe("saveDiagnosticLogs", () => { + it("should call fileDownloadService with log content", () => { + component.saveDiagnosticLogs(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("data-recovery-logs-"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should include timestamp in filename", () => { + component.saveDiagnosticLogs(); + + const downloadCall = mockFileDownloadService.download.mock.calls[0][0]; + expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts new file mode 100644 index 00000000000..31179dfb062 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts @@ -0,0 +1,208 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { SharedModule } from "../../shared"; + +import { LogRecorder } from "./log-recorder"; +import { + SyncStep, + UserInfoStep, + RecoveryStep, + PrivateKeyStep, + RecoveryWorkingData, + FolderStep, + CipherStep, +} from "./steps"; + +export const StepStatus = Object.freeze({ + NotStarted: 0, + InProgress: 1, + Completed: 2, + Failed: 3, +} as const); +export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus]; + +interface StepState { + title: string; + status: StepStatus; + message?: string; +} + +@Component({ + selector: "app-data-recovery", + templateUrl: "data-recovery.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, CommonModule, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DataRecoveryComponent { + protected readonly StepStatus = StepStatus; + + private i18nService = inject(I18nService); + private apiService = inject(ApiService); + private accountService = inject(AccountService); + private keyService = inject(KeyService); + private folderApiService = inject(FolderApiServiceAbstraction); + private cipherEncryptService = inject(CipherEncryptionService); + private dialogService = inject(DialogService); + private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService); + private cryptoFunctionService = inject(CryptoFunctionService); + private logService = inject(LogService); + private fileDownloadService = inject(FileDownloadService); + + private logger: LogRecorder = new LogRecorder(this.logService); + private recoverySteps: RecoveryStep[] = [ + new UserInfoStep(this.accountService, this.keyService), + new SyncStep(this.apiService), + new PrivateKeyStep( + this.privateKeyRegenerationService, + this.dialogService, + this.cryptoFunctionService, + ), + new FolderStep(this.folderApiService, this.dialogService), + new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService), + ]; + private workingData: RecoveryWorkingData | null = null; + + readonly status = signal(StepStatus.NotStarted); + readonly hasStarted = signal(false); + readonly diagnosticsCompleted = signal(false); + readonly recoveryCompleted = signal(false); + readonly steps = signal( + this.recoverySteps.map((step) => ({ + title: this.i18nService.t(step.title), + status: StepStatus.NotStarted, + })), + ); + readonly hasIssues = signal(false); + + runDiagnostics = async () => { + if (this.status() === StepStatus.InProgress) { + return; + } + + this.hasStarted.set(true); + this.status.set(StepStatus.InProgress); + this.diagnosticsCompleted.set(false); + + this.logger.record("Starting diagnostics..."); + this.workingData = { + userId: null, + userKey: null, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + this.diagnosticsCompleted.set(true); + }; + + private async runDiagnosticsInternal() { + if (!this.workingData) { + this.logger.record("No working data available"); + return; + } + + const currentSteps = this.steps(); + let hasAnyFailures = false; + + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + currentSteps[i].status = StepStatus.InProgress; + this.steps.set([...currentSteps]); + + this.logger.record(`Running diagnostics for step: ${step.title}`); + try { + const success = await step.runDiagnostics(this.workingData, this.logger); + currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed; + if (!success) { + hasAnyFailures = true; + } + this.steps.set([...currentSteps]); + this.logger.record(`Diagnostics completed for step: ${step.title}`); + } catch (error) { + currentSteps[i].status = StepStatus.Failed; + currentSteps[i].message = (error as Error).message; + this.steps.set([...currentSteps]); + this.logger.record( + `Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`, + ); + hasAnyFailures = true; + } + } + + if (hasAnyFailures) { + this.logger.record("Diagnostics completed with errors"); + } else { + this.logger.record("Diagnostics completed successfully"); + } + + // Check if any recovery can be performed + const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!)); + this.hasIssues.set(canRecoverAnyStep); + } + + runRecovery = async () => { + if (this.status() === StepStatus.InProgress || !this.workingData) { + return; + } + + this.status.set(StepStatus.InProgress); + this.recoveryCompleted.set(false); + + this.logger.record("Starting recovery process..."); + + try { + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + if (step.canRecover(this.workingData)) { + this.logger.record(`Running recovery for step: ${step.title}`); + await step.runRecovery(this.workingData, this.logger); + } + } + + this.logger.record("Recovery process completed"); + this.recoveryCompleted.set(true); + + // Re-run diagnostics after recovery + this.logger.record("Re-running diagnostics to verify recovery..."); + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + } catch (error) { + this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`); + this.status.set(StepStatus.Failed); + } + }; + + saveDiagnosticLogs = () => { + const logs = this.logger.getLogs(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `data-recovery-logs-${timestamp}.txt`; + + const logContent = logs.join("\n"); + this.fileDownloadService.download({ + fileName: filename, + blobData: logContent, + blobOptions: { type: "text/plain" }, + }); + + this.logger.record("Diagnostic logs saved"); + }; +} diff --git a/apps/web/src/app/key-management/data-recovery/log-recorder.ts b/apps/web/src/app/key-management/data-recovery/log-recorder.ts new file mode 100644 index 00000000000..1bca90de48d --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/log-recorder.ts @@ -0,0 +1,19 @@ +import { LogService } from "@bitwarden/logging"; + +/** + * Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere. + */ +export class LogRecorder { + private logs: string[] = []; + + constructor(private logService: LogService) {} + + record(message: string) { + this.logs.push(message); + this.logService.info(`[DataRecovery] ${message}`); + } + + getLogs(): string[] { + return [...this.logs]; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts new file mode 100644 index 00000000000..34e8cbdc9f3 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -0,0 +1,81 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { DialogService } from "@bitwarden/components"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class CipherStep implements RecoveryStep { + title = "recoveryStepCipherTitle"; + + private undecryptableCipherIds: string[] = []; + + constructor( + private apiService: ApiService, + private cipherService: CipherEncryptionService, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId) { + logger.record("Missing user ID"); + return false; + } + + this.undecryptableCipherIds = []; + for (const cipher of workingData.ciphers) { + try { + await this.cipherService.decrypt(cipher, workingData.userId); + } catch { + logger.record(`Cipher ID ${cipher.id} was undecryptable`); + this.undecryptableCipherIds.push(cipher.id); + } + } + logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`); + + return this.undecryptableCipherIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableCipherIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken ciphers. + if (this.undecryptableCipherIds.length === 0) { + logger.record("No undecryptable ciphers to recover"); + return; + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteCiphersTitle" }, + content: { key: "recoveryDeleteCiphersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled cipher deletion"); + throw new Error("Cipher recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`); + + for (const cipherId of this.undecryptableCipherIds) { + try { + await this.apiService.deleteCipher(cipherId); + logger.record(`Deleted cipher ${cipherId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`); + throw error; + } + } + + logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts new file mode 100644 index 00000000000..bc0ae31efba --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -0,0 +1,97 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class FolderStep implements RecoveryStep { + title = "recoveryStepFoldersTitle"; + + private undecryptableFolderIds: string[] = []; + + constructor( + private folderService: FolderApiServiceAbstraction, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userKey) { + logger.record("Missing user key"); + return false; + } + + this.undecryptableFolderIds = []; + for (const folder of workingData.folders) { + if (!folder.name?.encryptedString) { + logger.record(`Folder ID ${folder.id} has no name`); + this.undecryptableFolderIds.push(folder.id); + continue; + } + try { + await SdkLoadService.Ready; + PureCrypto.symmetric_decrypt_string( + folder.name.encryptedString, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record(`Folder name for folder ID ${folder.id} was undecryptable`); + this.undecryptableFolderIds.push(folder.id); + } + } + logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`); + + return this.undecryptableFolderIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableFolderIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken folders. + if (this.undecryptableFolderIds.length === 0) { + logger.record("No undecryptable folders to recover"); + return; + } + + if (!workingData.userId) { + logger.record("Missing user ID"); + throw new Error("Missing user ID"); + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteFoldersTitle" }, + content: { key: "recoveryDeleteFoldersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled folder deletion"); + throw new Error("Folder recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`); + + for (const folderId of this.undecryptableFolderIds) { + try { + await this.folderService.delete(folderId, workingData.userId); + logger.record(`Deleted folder ${folderId}`); + } catch (error) { + logger.record(`Failed to delete folder ${folderId}: ${error}`); + } + } + + logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`); + } + + getUndecryptableFolderIds(): string[] { + return this.undecryptableFolderIds; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/index.ts b/apps/web/src/app/key-management/data-recovery/steps/index.ts new file mode 100644 index 00000000000..caf3cdb34ef --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/index.ts @@ -0,0 +1,6 @@ +export * from "./sync-step"; +export * from "./user-info-step"; +export * from "./recovery-step"; +export * from "./private-key-step"; +export * from "./folder-step"; +export * from "./cipher-step"; diff --git a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts new file mode 100644 index 00000000000..82c20c466b8 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts @@ -0,0 +1,93 @@ +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { DialogService } from "@bitwarden/components"; +import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class PrivateKeyStep implements RecoveryStep { + title = "recoveryStepPrivateKeyTitle"; + + constructor( + private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService, + private dialogService: DialogService, + private cryptoFunctionService: CryptoFunctionService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId || !workingData.userKey) { + logger.record("Missing user ID or user key"); + return false; + } + + // Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation. + const encryptedPrivateKey = workingData.encryptedPrivateKey; + if (!encryptedPrivateKey) { + logger.record("No encrypted private key found"); + return false; + } + logger.record("Private key length: " + encryptedPrivateKey.length); + let privateKey: Uint8Array; + try { + await SdkLoadService.Ready; + privateKey = PureCrypto.unwrap_decapsulation_key( + encryptedPrivateKey, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record("Private key was un-decryptable"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + // Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding. + try { + const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + logger.record("Public key length: " + publicKey.length); + } catch { + logger.record("Public key could not be derived; private key is corrupt"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + // Only support recovery on V1 users. + return ( + workingData.isPrivateKeyCorrupt && + workingData.userKey !== null && + workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 + ); + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization. + // This is because this will break emergency access enrollments / organization memberships / provider memberships. + logger.record("Showing confirmation dialog for private key replacement"); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryReplacePrivateKeyTitle" }, + content: { key: "recoveryReplacePrivateKeyDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled private key replacement"); + throw new Error("Private key recovery cancelled by user"); + } + + logger.record("Replacing private key"); + await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair( + workingData.userId!, + ); + logger.record("Private key replaced successfully"); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts new file mode 100644 index 00000000000..265d7c68284 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts @@ -0,0 +1,43 @@ +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +/** + * A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers. + */ +export abstract class RecoveryStep { + /** Title of the recovery step, as an i18n key. */ + abstract title: string; + + /** + * Runs diagnostics on the provided working data. + * Returns true if no issues were found, false otherwise. + */ + abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; + + /** + * Returns whether recovery can be performed + */ + abstract canRecover(workingData: RecoveryWorkingData): boolean; + + /** + * Performs recovery on the provided working data. + */ + abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; +} + +/** + * Data used during the recovery process, passed between steps. + */ +export type RecoveryWorkingData = { + userId: UserId | null; + userKey: UserKey | null; + encryptedPrivateKey: WrappedPrivateKey | null; + isPrivateKeyCorrupt: boolean; + ciphers: Cipher[]; + folders: Folder[]; +}; diff --git a/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts new file mode 100644 index 00000000000..f0adb1e0b46 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts @@ -0,0 +1,43 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { FolderData } from "@bitwarden/common/vault/models/data/folder.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class SyncStep implements RecoveryStep { + title = "recoveryStepSyncTitle"; + + constructor(private apiService: ApiService) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The intent of this step is to fetch the latest data from the server. Diagnostics does not + // ever run on local data but only remote data that is recent. + const response = await this.apiService.getSync(); + + workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c))); + logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`); + + workingData.folders = response.folders.map((f) => new Folder(new FolderData(f))); + logger.record(`Fetched ${workingData.folders.length} folders from server`); + + workingData.encryptedPrivateKey = + response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null; + logger.record( + `Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts new file mode 100644 index 00000000000..9565b1da73b --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts @@ -0,0 +1,49 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { KeyService } from "@bitwarden/key-management"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class UserInfoStep implements RecoveryStep { + title = "recoveryStepUserInfoTitle"; + + constructor( + private accountService: AccountService, + private keyService: KeyService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (!activeAccount) { + logger.record("No active account found"); + return false; + } + const userId = activeAccount.id; + workingData.userId = userId; + logger.record(`User ID: ${userId}`); + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (!userKey) { + logger.record("No user key found"); + return false; + } + workingData.userKey = userKey; + logger.record( + `User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index b40b9143991..ac9bdc4b946 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; import { RouteDataProperties } from "./core"; import { ReportsModule } from "./dirt/reports"; +import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component"; import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; @@ -696,6 +697,12 @@ const routes: Routes = [ path: "security", loadChildren: () => SecurityRoutingModule, }, + { + path: "data-recovery", + component: DataRecoveryComponent, + canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)], + data: { titleId: "dataRecovery" } satisfies RouteDataProperties, + }, { path: "domain-rules", component: DomainRulesComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a755e4de556..c827f09d173 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12253,6 +12253,54 @@ "userVerificationFailed": { "message": "User verification failed." }, + "recoveryDeleteCiphersTitle": { + "message": "Delete unrecoverable vault items" + }, + "recoveryDeleteCiphersDesc": { + "message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?" + }, + "recoveryDeleteFoldersTitle": { + "message": "Delete unrecoverable folders" + }, + "recoveryDeleteFoldersDesc": { + "message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?" + }, + "recoveryReplacePrivateKeyTitle": { + "message": "Replace encryption key" + }, + "recoveryReplacePrivateKeyDesc": { + "message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again." + }, + "recoveryStepSyncTitle": { + "message": "Synchronizing data" + }, + "recoveryStepPrivateKeyTitle": { + "message": "Verifying encryption key integrity" + }, + "recoveryStepUserInfoTitle": { + "message": "Verifying user information" + }, + "recoveryStepCipherTitle": { + "message": "Verifying vault item integrity" + }, + "recoveryStepFoldersTitle": { + "message": "Verifying folder integrity" + }, + "dataRecoveryTitle": { + "message": "Data Recovery and Diagnostics" + }, + "dataRecoveryDescription": { + "message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues." + }, + "runDiagnostics": { + "message": "Run Diagnostics" + }, + "repairIssues": { + "message": "Repair Issues" + }, + "saveDiagnosticLogs": { + "message": "Save Diagnostic Logs" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1727d3da712..fb8edd8aa7d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -43,6 +43,7 @@ export enum FeatureFlag { LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", + DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", /* Tools */ @@ -149,6 +150,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, /* Platform */ diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts index 4703d836db7..58620f49ed1 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts @@ -7,4 +7,11 @@ export abstract class UserAsymmetricKeysRegenerationService { * @param userId The user id. */ abstract regenerateIfNeeded(userId: UserId): Promise; + + /** + * Performs the regeneration of the user's public/private key pair without checking any preconditions. + * This should only be used for V1 encryption accounts + * @param userId The user id. + */ + abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise; } diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts index e57ab74de6b..92e5240a187 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts @@ -370,3 +370,52 @@ describe("regenerateIfNeeded", () => { ); }); }); + +describe("regenerateUserPublicKeyEncryptionKeyPair", () => { + let sut: DefaultUserAsymmetricKeysRegenerationService; + const userId = "userId" as UserId; + + let keyService: MockProxy; + let cipherService: MockProxy; + let userAsymmetricKeysRegenerationApiService: MockProxy; + let logService: MockProxy; + let sdkService: MockSdkService; + let apiService: MockProxy; + let configService: MockProxy; + + beforeEach(() => { + keyService = mock(); + cipherService = mock(); + userAsymmetricKeysRegenerationApiService = mock(); + logService = mock(); + sdkService = new MockSdkService(); + apiService = mock(); + configService = mock(); + + sut = new DefaultUserAsymmetricKeysRegenerationService( + keyService, + cipherService, + userAsymmetricKeysRegenerationApiService, + logService, + sdkService, + apiService, + configService, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should throw error when user key is not V1 encryption type", async () => { + const mockUserKey = { + keyB64: "mockKeyB64", + inner: () => ({ type: 7 }), + } as unknown as UserKey; + keyService.userKey$.mockReturnValue(of(mockUserKey)); + + await expect(sut.regenerateUserPublicKeyEncryptionKeyPair(userId)).rejects.toThrow( + "User key is not V1 encryption type", + ); + }); +}); diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 335f45b0ce2..48fe3a1686f 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService if (privateKeyRegenerationFlag) { const shouldRegenerate = await this.shouldRegenerate(userId); if (shouldRegenerate) { - await this.regenerateUserAsymmetricKeys(userId); + await this.regenerateUserPublicKeyEncryptionKeyPair(userId); } } } catch (error) { @@ -125,11 +125,14 @@ export class DefaultUserAsymmetricKeysRegenerationService return false; } - private async regenerateUserAsymmetricKeys(userId: UserId): Promise { + async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (userKey == null) { throw new Error("User key not found"); } + if (userKey.inner().type !== EncryptionType.AesCbc256_HmacSha256_B64) { + throw new Error("User key is not V1 encryption type"); + } const makeKeyPairResponse = await firstValueFrom( this.sdkService.client$.pipe( map((sdk) => {