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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user