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

[PM-18680] biometric's no client key half provided for user (#13609)

* biometric's no client key half provided for user

Biometric's client key half can be optional (null) when the password is not required on start of the application

* improved unit test coverage

* ipc setClientKeyHalf can be null
This commit is contained in:
Maciej Zieniuk
2025-02-28 14:05:16 +01:00
committed by GitHub
parent 8176515c57
commit c80019e919
9 changed files with 453 additions and 204 deletions

View File

@@ -1,187 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
BiometricsService,
BiometricsStatus,
BiometricStateService,
} from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { MainBiometricsService } from "./main-biometrics.service";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
import { OsBiometricService } from "./os-biometrics.service";
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: jest.fn(),
passwords: jest.fn(),
};
});
describe("biometrics tests", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const logService = mock<LogService>();
const messagingService = mock<MessagingService>();
const biometricStateService = mock<BiometricStateService>();
it("Should call the platformspecific methods", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const mockService = mock<OsBiometricService>();
(sut as any).osBiometricsService = mockService;
await sut.authenticateBiometric();
expect(mockService.authenticateBiometric).toBeCalled();
});
describe("Should create a platform specific service", function () {
it("Should create a biometrics service specific for Windows", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"win32",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
});
it("Should create a biometrics service specific for MacOs", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"darwin",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceMac);
});
it("Should create a biometrics service specific for Linux", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"linux",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceLinux);
});
});
describe("can auth biometric", () => {
let sut: BiometricsService;
let innerService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
innerService = mock();
(sut as any).osBiometricsService = innerService;
});
it("should return the correct biometric status for system status", async () => {
const testCases = [
// happy path
[true, false, false, BiometricsStatus.Available],
[false, true, true, BiometricsStatus.HardwareUnavailable],
[true, true, true, BiometricsStatus.AutoSetupNeeded],
[true, true, false, BiometricsStatus.ManualSetupNeeded],
// should not happen
[false, false, true, BiometricsStatus.HardwareUnavailable],
[true, false, true, BiometricsStatus.Available],
[false, true, false, BiometricsStatus.HardwareUnavailable],
[false, false, false, BiometricsStatus.HardwareUnavailable],
];
for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) {
innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean);
innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean);
innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean);
const actual = await sut.getBiometricsStatus();
expect(actual).toBe(expected);
}
});
it("should return the correct biometric status for user status", async () => {
const testCases = [
// system status, biometric unlock enabled, require password on start, has key half, result
[BiometricsStatus.Available, false, false, false, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, true, false, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, false, true, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, true, true, BiometricsStatus.NotEnabledLocally],
[
BiometricsStatus.PlatformUnsupported,
true,
true,
true,
BiometricsStatus.PlatformUnsupported,
],
[BiometricsStatus.ManualSetupNeeded, true, true, true, BiometricsStatus.ManualSetupNeeded],
[BiometricsStatus.AutoSetupNeeded, true, true, true, BiometricsStatus.AutoSetupNeeded],
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
[BiometricsStatus.Available, true, true, false, BiometricsStatus.UnlockNeeded],
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
];
for (const [
systemStatus,
unlockEnabled,
requirePasswordOnStart,
hasKeyHalf,
expected,
] of testCases) {
sut.getBiometricsStatus = jest.fn().mockResolvedValue(systemStatus as BiometricsStatus);
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(unlockEnabled as boolean);
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(
requirePasswordOnStart as boolean,
);
(sut as any).clientKeyHalves = new Map();
const userId = "test" as UserId;
if (hasKeyHalf) {
(sut as any).clientKeyHalves.set(userId, "test");
}
const actual = await sut.getBiometricsStatusForUser(userId);
expect(actual).toBe(expected);
}
});
});
});

View File

@@ -11,5 +11,5 @@ export abstract class DesktopBiometricsService extends BiometricsService {
abstract setupBiometrics(): Promise<void>;
abstract setClientKeyHalfForUser(userId: UserId, value: string): Promise<void>;
abstract setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void>;
}

View File

@@ -44,9 +44,6 @@ export class MainBiometricsIPCListener {
message.userId as UserId,
);
case BiometricAction.SetClientKeyHalf:
if (message.key == null) {
return;
}
return await this.biometricService.setClientKeyHalfForUser(
message.userId as UserId,
message.key,

View File

@@ -0,0 +1,426 @@
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import {
BiometricsService,
BiometricsStatus,
BiometricStateService,
} from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { MainBiometricsService } from "./main-biometrics.service";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
import { OsBiometricService } from "./os-biometrics.service";
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: jest.fn(),
passwords: jest.fn(),
};
});
describe("MainBiometricsService", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const logService = mock<LogService>();
const messagingService = mock<MessagingService>();
const biometricStateService = mock<BiometricStateService>();
it("Should call the platformspecific methods", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const mockService = mock<OsBiometricService>();
(sut as any).osBiometricsService = mockService;
await sut.authenticateBiometric();
expect(mockService.authenticateBiometric).toBeCalled();
});
describe("Should create a platform specific service", function () {
it("Should create a biometrics service specific for Windows", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"win32",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
});
it("Should create a biometrics service specific for MacOs", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"darwin",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceMac);
});
it("Should create a biometrics service specific for Linux", () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
"linux",
biometricStateService,
);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceLinux);
});
});
describe("can auth biometric", () => {
let sut: BiometricsService;
let innerService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
innerService = mock();
(sut as any).osBiometricsService = innerService;
});
it("should return the correct biometric status for system status", async () => {
const testCases = [
// happy path
[true, false, false, BiometricsStatus.Available],
[false, true, true, BiometricsStatus.HardwareUnavailable],
[true, true, true, BiometricsStatus.AutoSetupNeeded],
[true, true, false, BiometricsStatus.ManualSetupNeeded],
// should not happen
[false, false, true, BiometricsStatus.HardwareUnavailable],
[true, false, true, BiometricsStatus.Available],
[false, true, false, BiometricsStatus.HardwareUnavailable],
[false, false, false, BiometricsStatus.HardwareUnavailable],
];
for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) {
innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean);
innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean);
innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean);
const actual = await sut.getBiometricsStatus();
expect(actual).toBe(expected);
}
});
it("should return the correct biometric status for user status", async () => {
const testCases = [
// system status, biometric unlock enabled, require password on start, has key half, result
[BiometricsStatus.Available, false, false, false, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, true, false, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, false, true, BiometricsStatus.NotEnabledLocally],
[BiometricsStatus.Available, false, true, true, BiometricsStatus.NotEnabledLocally],
[
BiometricsStatus.PlatformUnsupported,
true,
true,
true,
BiometricsStatus.PlatformUnsupported,
],
[BiometricsStatus.ManualSetupNeeded, true, true, true, BiometricsStatus.ManualSetupNeeded],
[BiometricsStatus.AutoSetupNeeded, true, true, true, BiometricsStatus.AutoSetupNeeded],
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
[BiometricsStatus.Available, true, true, false, BiometricsStatus.UnlockNeeded],
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
];
for (const [
systemStatus,
unlockEnabled,
requirePasswordOnStart,
hasKeyHalf,
expected,
] of testCases) {
sut.getBiometricsStatus = jest.fn().mockResolvedValue(systemStatus as BiometricsStatus);
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(unlockEnabled as boolean);
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(
requirePasswordOnStart as boolean,
);
(sut as any).clientKeyHalves = new Map();
const userId = "test" as UserId;
if (hasKeyHalf) {
(sut as any).clientKeyHalves.set(userId, "test");
}
const actual = await sut.getBiometricsStatusForUser(userId);
expect(actual).toBe(expected);
}
});
});
describe("setupBiometrics", () => {
it("should call the platform specific setup method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.setupBiometrics();
expect(osBiometricsService.osBiometricsSetup).toHaveBeenCalled();
});
});
describe("setClientKeyHalfForUser", () => {
let sut: MainBiometricsService;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
});
it("should set the client key half for the user", async () => {
const userId = "test" as UserId;
const keyHalf = "testKeyHalf";
await sut.setClientKeyHalfForUser(userId, keyHalf);
expect((sut as any).clientKeyHalves.has(userId)).toBe(true);
expect((sut as any).clientKeyHalves.get(userId)).toBe(keyHalf);
});
it("should reset the client key half for the user", async () => {
const userId = "test" as UserId;
await sut.setClientKeyHalfForUser(userId, null);
expect((sut as any).clientKeyHalves.has(userId)).toBe(true);
expect((sut as any).clientKeyHalves.get(userId)).toBe(null);
});
});
describe("authenticateWithBiometrics", () => {
it("should call the platform specific authenticate method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.authenticateWithBiometrics();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
});
describe("unlockWithBiometricsForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("should return null if no biometric key is returned ", async () => {
const userId = "test" as UserId;
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
const userKey = await sut.unlockWithBiometricsForUser(userId);
expect(userKey).toBeNull();
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
"testKeyHalf",
);
});
it("should return the biometric key if a valid key is returned", async () => {
const userId = "test" as UserId;
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
const biometricKey = Utils.fromBufferToB64(new Uint8Array(64));
osBiometricsService.getBiometricKey.mockResolvedValue(biometricKey);
const userKey = await sut.unlockWithBiometricsForUser(userId);
expect(userKey).not.toBeNull();
expect(userKey!.keyB64).toBe(biometricKey);
expect(userKey!.encType).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
"testKeyHalf",
);
});
});
describe("setBiometricProtectedUnlockKeyForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("should throw an error if no client key half is provided", async () => {
const userId = "test" as UserId;
const unlockKey = "testUnlockKey";
await expect(sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey)).rejects.toThrow(
"No client key half provided for user",
);
});
it("should call the platform specific setBiometricKey method", async () => {
const userId = "test" as UserId;
const unlockKey = "testUnlockKey";
(sut as any).clientKeyHalves.set(userId, "testKeyHalf");
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
unlockKey,
"testKeyHalf",
);
});
});
describe("deleteBiometricUnlockKeyForUser", () => {
it("should call the platform specific deleteBiometricKey method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
const userId = "test" as UserId;
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(
"Bitwarden_biometric",
`${userId}_user_biometric`,
);
});
});
describe("setShouldAutopromptNow", () => {
let sut: MainBiometricsService;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
});
it("should set shouldAutopromptNow to false", async () => {
await sut.setShouldAutopromptNow(false);
const shouldAutoPrompt = await sut.getShouldAutopromptNow();
expect(shouldAutoPrompt).toBe(false);
});
it("should set shouldAutopromptNow to true", async () => {
await sut.setShouldAutopromptNow(true);
const shouldAutoPrompt = await sut.getShouldAutopromptNow();
expect(shouldAutoPrompt).toBe(true);
});
});
describe("getShouldAutopromptNow", () => {
it("defaults shouldAutoPrompt is true", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
messagingService,
process.platform,
biometricStateService,
);
const shouldAutoPrompt = await sut.getShouldAutopromptNow();
expect(shouldAutoPrompt).toBe(true);
});
});
});

View File

@@ -13,7 +13,7 @@ import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private clientKeyHalves = new Map<string, string>();
private clientKeyHalves = new Map<string, string | null>();
private shouldAutoPrompt = true;
constructor(
@@ -104,7 +104,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
return await this.osBiometricsService.osBiometricsSetup();
}
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void> {
this.clientKeyHalves.set(userId, value);
}
@@ -116,7 +116,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
const biometricKey = await this.osBiometricsService.getBiometricKey(
"Bitwarden_biometric",
`${userId}_user_biometric`,
this.clientKeyHalves.get(userId),
this.clientKeyHalves.get(userId) ?? undefined,
);
if (biometricKey == null) {
return null;
@@ -136,7 +136,7 @@ export class MainBiometricsService extends DesktopBiometricsService {
service,
storageKey,
value,
this.clientKeyHalves.get(userId),
this.clientKeyHalves.get(userId) ?? undefined,
);
}

View File

@@ -40,7 +40,7 @@ export class RendererBiometricsService extends DesktopBiometricsService {
return await ipc.keyManagement.biometric.setupBiometrics();
}
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
async setClientKeyHalfForUser(userId: UserId, value: string | null): Promise<void> {
return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value);
}

View File

@@ -39,7 +39,7 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.Setup,
} satisfies BiometricMessage),
setClientKeyHalf: (userId: string, value: string): Promise<void> =>
setClientKeyHalf: (userId: string, value: string | null): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.SetClientKeyHalf,
userId: userId,

View File

@@ -81,7 +81,7 @@ export class ElectronKeyService extends DefaultKeyService {
// May resolve to null, in which case no client key have is required
// TODO: Move to windows implementation
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId);
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf as string);
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf);
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64);
}

View File

@@ -15,9 +15,22 @@ export enum BiometricAction {
SetShouldAutoprompt = "setShouldAutoprompt",
}
export type BiometricMessage = {
action: BiometricAction;
key?: string;
userId?: string;
data?: any;
};
export type BiometricMessage =
| {
action: BiometricAction.SetClientKeyHalf;
userId: string;
key: string | null;
}
| {
action: BiometricAction.SetKeyForUser;
userId: string;
key: string;
}
| {
action: Exclude<
BiometricAction,
BiometricAction.SetClientKeyHalf | BiometricAction.SetKeyForUser
>;
userId?: string;
data?: any;
};