1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-09 03:53:53 +00:00

[PM-28813] Implement encryption diagnostics & recovery tool (#17673)

* Implement data recovery tool

* Fix tests

* Move Sdkloadservice call and use bit action
This commit is contained in:
Bernd Schoolmann
2025-12-10 04:03:31 +01:00
committed by GitHub
parent 42c09b325c
commit 3af19ad934
17 changed files with 1180 additions and 2 deletions

View File

@@ -0,0 +1,75 @@
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "dataRecoveryTitle" | i18n }}</h2>
<div class="tw-max-w-lg">
<p bitTypography="body1" class="tw-mb-4">
{{ "dataRecoveryDescription" | i18n }}
</p>
@if (!diagnosticsCompleted() && !recoveryCompleted()) {
<button
type="button"
bitButton
buttonType="primary"
[bitAction]="runDiagnostics"
class="tw-mb-6"
>
{{ "runDiagnostics" | i18n }}
</button>
}
<div class="tw-space-y-3 tw-mb-6">
@for (step of steps(); track $index) {
@if (
($index === 0 && hasStarted()) ||
($index > 0 &&
(steps()[$index - 1].status === StepStatus.Completed ||
steps()[$index - 1].status === StepStatus.Failed))
) {
<div class="tw-flex tw-items-start tw-gap-3">
<div class="tw-mt-1">
@if (step.status === StepStatus.Failed) {
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
} @else if (step.status === StepStatus.Completed) {
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
} @else if (step.status === StepStatus.InProgress) {
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
} @else {
<i class="bwi bwi-circle tw-text-secondary-300" aria-hidden="true"></i>
}
</div>
<div>
<span
[class.tw-text-danger]="step.status === StepStatus.Failed"
[class.tw-text-success]="step.status === StepStatus.Completed"
[class.tw-text-primary-600]="step.status === StepStatus.InProgress"
[class.tw-font-semibold]="step.status === StepStatus.InProgress"
[class.tw-text-secondary-500]="step.status === StepStatus.NotStarted"
>
{{ step.title }}
</span>
</div>
</div>
}
}
</div>
@if (diagnosticsCompleted()) {
<div class="tw-flex tw-gap-3">
@if (hasIssues() && !recoveryCompleted()) {
<button
type="button"
bitButton
buttonType="primary"
[disabled]="status() === StepStatus.InProgress"
[bitAction]="runRecovery"
>
{{ "repairIssues" | i18n }}
</button>
}
<button type="button" bitButton buttonType="secondary" [bitAction]="saveDiagnosticLogs">
<i class="bwi bwi-download" aria-hidden="true"></i>
{{ "saveDiagnosticLogs" | i18n }}
</button>
</div>
}
</div>

View File

@@ -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<DataRecoveryComponent>;
// Mock Services
let mockI18nService: MockProxy<I18nService>;
let mockApiService: MockProxy<ApiService>;
let mockAccountService: FakeAccountService;
let mockKeyService: MockProxy<KeyService>;
let mockFolderApiService: MockProxy<FolderApiServiceAbstraction>;
let mockCipherEncryptService: MockProxy<CipherEncryptionService>;
let mockDialogService: MockProxy<DialogService>;
let mockPrivateKeyRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
let mockLogService: MockProxy<LogService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockFileDownloadService: MockProxy<FileDownloadService>;
const mockUserId = "user-id" as UserId;
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
mockAccountService = mockAccountServiceWith(mockUserId);
mockKeyService = mock<KeyService>();
mockFolderApiService = mock<FolderApiServiceAbstraction>();
mockCipherEncryptService = mock<CipherEncryptionService>();
mockDialogService = mock<DialogService>();
mockPrivateKeyRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
mockLogService = mock<LogService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockFileDownloadService = mock<FileDownloadService>();
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<RecoveryStep>[];
beforeEach(() => {
// Create mock steps
mockSteps = Array(5)
.fill(null)
.map(() => {
const mockStep = mock<RecoveryStep>();
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<RecoveryStep>[];
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<RecoveryStep>();
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/);
});
});
});

View File

@@ -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>(StepStatus.NotStarted);
readonly hasStarted = signal(false);
readonly diagnosticsCompleted = signal(false);
readonly recoveryCompleted = signal(false);
readonly steps = signal<StepState[]>(
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");
};
}

View File

@@ -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];
}
}

View File

@@ -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<boolean> {
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<void> {
// 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`);
}
}

View File

@@ -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<boolean> {
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<void> {
// 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;
}
}

View File

@@ -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";

View File

@@ -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<boolean> {
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<void> {
// 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");
}
}

View File

@@ -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<boolean>;
/**
* 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<void>;
}
/**
* 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[];
};

View File

@@ -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<boolean> {
// 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<void> {
return Promise.resolve();
}
}

View File

@@ -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<boolean> {
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<void> {
return Promise.resolve();
}
}

View File

@@ -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,