1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00
This commit is contained in:
Bernd Schoolmann 2025-12-05 01:32:00 +01:00 committed by GitHub
commit b8def5251d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1173 additions and 2 deletions

View File

@ -0,0 +1,84 @@
<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"
[disabled]="isRunning()"
(click)="runDiagnostics()"
class="tw-mb-6"
>
@if (isRunning()) {
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i>
{{ "runningDiagnostics" | i18n }}
} @else {
{{ "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].completed || steps()[$index - 1].failed))
) {
<div class="tw-flex tw-items-start tw-gap-3">
<div class="tw-mt-1">
@if (step.failed) {
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
} @else if (step.completed) {
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
} @else if (step.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.failed"
[class.tw-text-success]="step.completed"
[class.tw-text-primary-600]="step.inProgress"
[class.tw-font-semibold]="step.inProgress"
[class.tw-text-secondary-500]="!step.completed && !step.inProgress && !step.failed"
>
{{ 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]="isRunning()"
(click)="runRecovery()"
>
@if (isRunning()) {
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i>
{{ "repairing" | i18n }}
} @else {
{{ "repairIssues" | i18n }}
}
</button>
}
<button type="button" bitButton buttonType="secondary" (click)="saveDiagnosticLogs()">
<i class="bwi bwi-download" aria-hidden="true"></i>
{{ "saveDiagnosticLogs" | i18n }}
</button>
</div>
}
</div>

View File

@ -0,0 +1,356 @@
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 { 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 } 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>;
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>();
mockI18nService.t.mockImplementation((key: string) => key);
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 },
],
}).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.isRunning()).toBe(false);
expect(component.isCompleted()).toBe(false);
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");
expect(steps[1].title).toBe("recoveryStepSyncTitle");
expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle");
expect(steps[3].title).toBe("recoveryStepFoldersTitle");
expect(steps[4].title).toBe("recoveryStepCipherTitle");
});
});
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["isRunning"].set(true);
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.completed).toBe(true);
expect(step.failed).toBe(false);
expect(step.inProgress).toBe(false);
});
});
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].completed).toBe(false);
expect(steps[2].failed).toBe(true);
});
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].failed).toBe(true);
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 stop running when complete", async () => {
await component.runDiagnostics();
expect(component.diagnosticsCompleted()).toBe(true);
expect(component.isRunning()).toBe(false);
});
});
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["isRunning"].set(true);
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 isCompleted when successful", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
await component.runRecovery();
expect(component.recoveryCompleted()).toBe(true);
expect(component.isCompleted()).toBe(true);
expect(component.isRunning()).toBe(false);
});
it("should stop running animation if recovery is cancelled", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
await component.runRecovery();
expect(component.isRunning()).toBe(false);
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 create and download a log file", () => {
const mockCreateElement = jest.spyOn(document, "createElement");
const mockClick = jest.fn();
const mockLink = {
href: "",
download: "",
click: mockClick,
} as any;
mockCreateElement.mockReturnValue(mockLink);
const mockCreateObjectURL = jest.fn().mockReturnValue("blob:mock-url");
const mockRevokeObjectURL = jest.fn();
global.URL.createObjectURL = mockCreateObjectURL;
global.URL.revokeObjectURL = mockRevokeObjectURL;
component.saveDiagnosticLogs();
expect(mockCreateElement).toHaveBeenCalledWith("a");
expect(mockLink.download).toContain("data-recovery-logs-");
expect(mockLink.download).toContain(".txt");
expect(mockClick).toHaveBeenCalled();
expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
});
});
});

View File

@ -0,0 +1,223 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.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";
interface StepState {
title: string;
completed: boolean;
inProgress: boolean;
failed: boolean;
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 {
private logger: LogRecorder;
private recoverySteps: RecoveryStep[] = [];
private workingData: RecoveryWorkingData | null = null;
readonly isRunning = signal(false);
readonly isCompleted = signal(false);
readonly hasStarted = signal(false);
readonly diagnosticsCompleted = signal(false);
readonly recoveryCompleted = signal(false);
readonly steps = signal<StepState[]>([]);
readonly hasIssues = signal(false);
constructor(
private i18nService: I18nService,
private apiService: ApiService,
private accountService: AccountService,
private keyService: KeyService,
private folderApiService: FolderApiServiceAbstraction,
private cipherEncryptService: CipherEncryptionService,
private dialogService: DialogService,
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
private cryptoFunctionService: CryptoFunctionService,
logService: LogService,
) {
this.logger = new LogRecorder(logService);
this.recoverySteps = [
new UserInfoStep(this.accountService, this.keyService),
new SyncStep(this.apiService),
new PrivateKeyStep(
this.keyService,
this.privateKeyRegenerationService,
this.dialogService,
this.cryptoFunctionService,
),
new FolderStep(this.folderApiService, this.dialogService),
new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
];
// Initialize step states for UI
this.steps.set(
this.recoverySteps.map((step) => ({
title: this.i18nService.t(step.title),
completed: false,
inProgress: false,
failed: false,
})),
);
}
async runDiagnostics() {
if (this.isRunning()) {
return;
}
// Ensure SDK is loaded
await SdkLoadService.Ready;
this.hasStarted.set(true);
this.isRunning.set(true);
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.isRunning.set(false);
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].inProgress = true;
currentSteps[i].completed = false;
currentSteps[i].failed = false;
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].inProgress = false;
currentSteps[i].completed = success;
if (!success) {
currentSteps[i].failed = true;
hasAnyFailures = true;
}
this.steps.set([...currentSteps]);
this.logger.record(`Diagnostics completed for step: ${step.title}`);
} catch (error) {
currentSteps[i].inProgress = false;
currentSteps[i].failed = true;
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);
}
async runRecovery() {
if (this.isRunning() || !this.workingData) {
return;
}
this.isRunning.set(true);
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.isCompleted.set(true);
} catch (error) {
this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
} finally {
this.isRunning.set(false);
}
}
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");
const blob = new Blob([logContent], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
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,83 @@
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 extends RecoveryStep {
title = "recoveryStepCipherTitle";
private undecryptableCipherIds: string[] = [];
constructor(
private apiService: ApiService,
private cipherService: CipherEncryptionService,
private dialogService: DialogService,
) {
super();
}
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,99 @@
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 extends RecoveryStep {
title = "recoveryStepFoldersTitle";
private undecryptableFolderIds: string[] = [];
constructor(
private folderService: FolderApiServiceAbstraction,
private dialogService: DialogService,
) {
super();
}
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 {
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) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.record(`Failed to delete folder ${folderId}: ${errorMessage}`);
throw 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,97 @@
import { firstValueFrom } from "rxjs";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { DialogService } from "@bitwarden/components";
import { KeyService, 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 extends RecoveryStep {
title = "recoveryStepPrivateKeyTitle";
constructor(
private keyService: KeyService,
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
private dialogService: DialogService,
private cryptoFunctionService: CryptoFunctionService,
) {
super();
}
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 = await firstValueFrom(
this.keyService.userEncryptedPrivateKey$(workingData.userId),
);
if (!encryptedPrivateKey) {
logger.record("No encrypted private key found");
return false;
}
logger.record("Private key length: " + encryptedPrivateKey.length);
let privateKey: Uint8Array;
try {
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. 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,45 @@
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 extends RecoveryStep {
title = "recoveryStepSyncTitle";
constructor(private apiService: ApiService) {
super();
}
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,51 @@
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 extends RecoveryStep {
title = "recoveryStepUserInfoTitle";
constructor(
private accountService: AccountService,
private keyService: KeyService,
) {
super();
}
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,

View File

@ -12214,5 +12214,59 @@
},
"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"
},
"runningDiagnostics": {
"message": "Running Diagnostics..."
},
"repairIssues": {
"message": "Repair Issues"
},
"repairing": {
"message": "Repairing..."
},
"saveDiagnosticLogs": {
"message": "Save Diagnostic Logs"
}
}

View File

@ -42,6 +42,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 */
@ -143,6 +144,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */

View File

@ -7,4 +7,6 @@ export abstract class UserAsymmetricKeysRegenerationService {
* @param userId The user id.
*/
abstract regenerateIfNeeded(userId: UserId): Promise<void>;
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>;
}

View File

@ -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,7 +125,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
return false;
}
private async regenerateUserAsymmetricKeys(userId: UserId): Promise<void> {
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("User key not found");