1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00

[PM-29929] Exclude organization vault items in data recovery tool (#18044)

* Exclude organization vault items in data recovery tool

* Allow undefined organization id
This commit is contained in:
Bernd Schoolmann
2025-12-21 21:46:18 +01:00
committed by GitHub
parent ea975610e6
commit 2d6d1dfe53
2 changed files with 246 additions and 1 deletions

View File

@@ -0,0 +1,241 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { DialogService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
import { LogRecorder } from "../log-recorder";
import { CipherStep } from "./cipher-step";
import { RecoveryWorkingData } from "./recovery-step";
describe("CipherStep", () => {
let cipherStep: CipherStep;
let apiService: MockProxy<ApiService>;
let cipherEncryptionService: MockProxy<CipherEncryptionService>;
let dialogService: MockProxy<DialogService>;
let logger: MockProxy<LogRecorder>;
beforeEach(() => {
apiService = mock<ApiService>();
cipherEncryptionService = mock<CipherEncryptionService>();
dialogService = mock<DialogService>();
logger = mock<LogRecorder>();
cipherStep = new CipherStep(apiService, cipherEncryptionService, dialogService);
});
describe("runDiagnostics", () => {
it("returns false and logs error when userId is missing", async () => {
const workingData: RecoveryWorkingData = {
userId: null,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [],
folders: [],
};
const result = await cipherStep.runDiagnostics(workingData, logger);
expect(result).toBe(false);
expect(logger.record).toHaveBeenCalledWith("Missing user ID");
});
it("returns true when all user ciphers are decryptable", async () => {
const userId = "user-id" as UserId;
const cipher1 = { id: "cipher-1", organizationId: null } as Cipher;
const cipher2 = { id: "cipher-2", organizationId: null } as Cipher;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [cipher1, cipher2],
folders: [],
};
cipherEncryptionService.decrypt.mockResolvedValue({} as any);
const result = await cipherStep.runDiagnostics(workingData, logger);
expect(result).toBe(true);
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(cipher1, userId);
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(cipher2, userId);
});
it("filters out organization ciphers (organizationId !== null) and only processes user ciphers", async () => {
const userId = "user-id" as UserId;
const userCipher = { id: "user-cipher", organizationId: null } as Cipher;
const orgCipher1 = { id: "org-cipher-1", organizationId: "org-1" } as Cipher;
const orgCipher2 = { id: "org-cipher-2", organizationId: "org-2" } as Cipher;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [userCipher, orgCipher1, orgCipher2],
folders: [],
};
cipherEncryptionService.decrypt.mockResolvedValue({} as any);
const result = await cipherStep.runDiagnostics(workingData, logger);
expect(result).toBe(true);
// Only user cipher should be processed
expect(cipherEncryptionService.decrypt).toHaveBeenCalledTimes(1);
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(userCipher, userId);
// Organization ciphers should not be processed
expect(cipherEncryptionService.decrypt).not.toHaveBeenCalledWith(orgCipher1, userId);
expect(cipherEncryptionService.decrypt).not.toHaveBeenCalledWith(orgCipher2, userId);
});
it("returns false and records undecryptable user ciphers", async () => {
const userId = "user-id" as UserId;
const cipher1 = { id: "cipher-1", organizationId: null } as Cipher;
const cipher2 = { id: "cipher-2", organizationId: null } as Cipher;
const cipher3 = { id: "cipher-3", organizationId: null } as Cipher;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [cipher1, cipher2, cipher3],
folders: [],
};
cipherEncryptionService.decrypt
.mockResolvedValueOnce({} as any) // cipher1 succeeds
.mockRejectedValueOnce(new Error("Decryption failed")) // cipher2 fails
.mockRejectedValueOnce(new Error("Decryption failed")); // cipher3 fails
const result = await cipherStep.runDiagnostics(workingData, logger);
expect(result).toBe(false);
expect(logger.record).toHaveBeenCalledWith("Cipher ID cipher-2 was undecryptable");
expect(logger.record).toHaveBeenCalledWith("Cipher ID cipher-3 was undecryptable");
expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable ciphers");
});
});
describe("canRecover", () => {
it("returns false when there are no undecryptable ciphers", async () => {
const userId = "user-id" as UserId;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [{ id: "cipher-1", organizationId: null } as Cipher],
folders: [],
};
cipherEncryptionService.decrypt.mockResolvedValue({} as any);
await cipherStep.runDiagnostics(workingData, logger);
const result = cipherStep.canRecover(workingData);
expect(result).toBe(false);
});
it("returns true when there are undecryptable ciphers", async () => {
const userId = "user-id" as UserId;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [{ id: "cipher-1", organizationId: null } as Cipher],
folders: [],
};
cipherEncryptionService.decrypt.mockRejectedValue(new Error("Decryption failed"));
await cipherStep.runDiagnostics(workingData, logger);
const result = cipherStep.canRecover(workingData);
expect(result).toBe(true);
});
});
describe("runRecovery", () => {
it("logs and returns early when there are no undecryptable ciphers", async () => {
const workingData: RecoveryWorkingData = {
userId: "user-id" as UserId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [],
folders: [],
};
await cipherStep.runRecovery(workingData, logger);
expect(logger.record).toHaveBeenCalledWith("No undecryptable ciphers to recover");
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(apiService.deleteCipher).not.toHaveBeenCalled();
});
it("throws error when user cancels deletion", async () => {
const userId = "user-id" as UserId;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [{ id: "cipher-1", organizationId: null } as Cipher],
folders: [],
};
cipherEncryptionService.decrypt.mockRejectedValue(new Error("Decryption failed"));
await cipherStep.runDiagnostics(workingData, logger);
dialogService.openSimpleDialog.mockResolvedValue(false);
await expect(cipherStep.runRecovery(workingData, logger)).rejects.toThrow(
"Cipher recovery cancelled by user",
);
expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 1 ciphers");
expect(logger.record).toHaveBeenCalledWith("User cancelled cipher deletion");
expect(apiService.deleteCipher).not.toHaveBeenCalled();
});
it("deletes undecryptable ciphers when user confirms", async () => {
const userId = "user-id" as UserId;
const cipher1 = { id: "cipher-1", organizationId: null } as Cipher;
const cipher2 = { id: "cipher-2", organizationId: null } as Cipher;
const workingData: RecoveryWorkingData = {
userId,
userKey: null,
encryptedPrivateKey: null,
isPrivateKeyCorrupt: false,
ciphers: [cipher1, cipher2],
folders: [],
};
cipherEncryptionService.decrypt.mockRejectedValue(new Error("Decryption failed"));
await cipherStep.runDiagnostics(workingData, logger);
dialogService.openSimpleDialog.mockResolvedValue(true);
apiService.deleteCipher.mockResolvedValue(undefined);
await cipherStep.runRecovery(workingData, logger);
expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 2 ciphers");
expect(logger.record).toHaveBeenCalledWith("Deleting 2 ciphers");
expect(apiService.deleteCipher).toHaveBeenCalledWith("cipher-1");
expect(apiService.deleteCipher).toHaveBeenCalledWith("cipher-2");
expect(logger.record).toHaveBeenCalledWith("Deleted cipher cipher-1");
expect(logger.record).toHaveBeenCalledWith("Deleted cipher cipher-2");
expect(logger.record).toHaveBeenCalledWith("Successfully deleted 2 ciphers");
});
});
});

View File

@@ -24,7 +24,11 @@ export class CipherStep implements RecoveryStep {
}
this.undecryptableCipherIds = [];
for (const cipher of workingData.ciphers) {
// The tool is currently only implemented to handle ciphers that are corrupt for a user. For an organization, the case of
// local user not having access to the organization key is not properly handled here, and should be implemented separately.
// For now, this just filters out and does not consider corrupt organization ciphers.
const userCiphers = workingData.ciphers.filter((c) => c.organizationId == null);
for (const cipher of userCiphers) {
try {
await this.cipherService.decrypt(cipher, workingData.userId);
} catch {