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

[PM-26498] Add proofOfDecryption method to MasterPasswordUnlockService (#17322)

* Add proofOfDecryption method to MasterPasswordUnlockService
This commit is contained in:
Thomas Avery
2025-11-13 14:06:56 -06:00
committed by GitHub
parent e88720d4ed
commit 35f35c4361
5 changed files with 148 additions and 3 deletions

View File

@@ -7,7 +7,21 @@ export abstract class MasterPasswordUnlockService {
* Unlocks the user's account using the master password.
* @param masterPassword The master password provided by the user.
* @param userId The ID of the active user.
* @throws If the master password provided is null/undefined/empty.
* @throws If the userId provided is null/undefined.
* @throws if the masterPasswordUnlockData for the user is not found.
* @throws If unwrapping the user key fails.
* @returns the user's decrypted userKey.
*/
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
/**
* For the given master password and user ID, verifies whether the user can decrypt their user key stored in state.
* @param masterPassword The master password provided by the user.
* @param userId The ID of the active user.
* @throws If the master password provided is null/undefined/empty.
* @throws If the userId provided is null/undefined.
* @returns true if the userKey can be decrypted, false otherwise.
*/
abstract proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean>;
}

View File

@@ -4,6 +4,8 @@ import { of } from "rxjs";
import { newGuid } from "@bitwarden/guid";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { CryptoError } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
import { HashPurpose } from "../../../platform/enums";
@@ -23,6 +25,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let keyService: MockProxy<KeyService>;
let logService: MockProxy<LogService>;
const mockMasterPassword = "testExample";
const mockUserId = newGuid() as UserId;
@@ -41,8 +44,9 @@ describe("DefaultMasterPasswordUnlockService", () => {
beforeEach(() => {
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
keyService = mock<KeyService>();
logService = mock<LogService>();
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService, logService);
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
of(mockMasterPasswordUnlockData),
@@ -73,7 +77,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
);
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided master password is %s",
"throws when the provided userID is %s",
async (userId) => {
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
"User ID is required",
@@ -151,4 +155,90 @@ describe("DefaultMasterPasswordUnlockService", () => {
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
});
});
describe("proofOfDecryption", () => {
test.each([null as unknown as string, undefined as unknown as string, ""])(
"throws when the provided master password is %s",
async (masterPassword) => {
await expect(sut.proofOfDecryption(masterPassword, mockUserId)).rejects.toThrow(
"Master password is required",
);
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
expect(
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
).not.toHaveBeenCalled();
},
);
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userID is %s",
async (userId) => {
await expect(sut.proofOfDecryption(mockMasterPassword, userId)).rejects.toThrow(
"User ID is required",
);
},
);
it("returns false when the user doesn't have masterPasswordUnlockData", async () => {
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
).not.toHaveBeenCalled();
expect(logService.warning).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${mockUserId} returning false.`,
);
});
it("returns true when the master password is correct", async () => {
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(true);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
});
it("returns false when the master password is incorrect", async () => {
const error = new Error("Incorrect password") as CryptoError;
error.name = "CryptoError";
error.variant = "InvalidKey";
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
expect(logService.debug).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${mockUserId} returning false: ${error}`,
);
});
it("returns false when a generic error occurs", async () => {
const error = new Error("Generic error");
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
expect(logService.error).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${mockUserId} returning false: ${error}`,
);
});
});
});

View File

@@ -2,6 +2,8 @@ import { firstValueFrom } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { isCryptoError } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
import { HashPurpose } from "../../../platform/enums";
@@ -14,6 +16,7 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
constructor(
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
private readonly keyService: KeyService,
private readonly logService: LogService,
) {}
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
@@ -37,6 +40,43 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
return userKey;
}
async proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean> {
this.validateInput(masterPassword, userId);
try {
const masterPasswordUnlockData = await firstValueFrom(
this.masterPasswordService.masterPasswordUnlockData$(userId),
);
if (masterPasswordUnlockData == null) {
this.logService.warning(
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${userId} returning false.`,
);
return false;
}
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
masterPassword,
masterPasswordUnlockData,
);
return userKey != null;
} catch (error) {
// masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData is expected to throw if the password is incorrect.
// Currently this throws CryptoError:InvalidKey if decrypting the user key fails at all.
if (isCryptoError(error) && error.variant === "InvalidKey") {
this.logService.debug(
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${userId} returning false: ${error}`,
);
} else {
this.logService.error(
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${userId} returning false: ${error}`,
);
}
return false;
}
}
private validateInput(masterPassword: string, userId: UserId): void {
if (masterPassword == null || masterPassword === "") {
throw new Error("Master password is required");