1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 04:03:29 +00:00
This commit is contained in:
Bernd Schoolmann
2025-08-28 13:17:27 +02:00
parent e8e7e6781a
commit 27e5b043a0
6 changed files with 23 additions and 723 deletions

View File

@@ -32,10 +32,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
this.osBiometricsService = new OsBiometricsServiceWindows(
this.i18nService,
this.windowMain,
this.logService,
this.biometricStateService,
this.encryptService,
this.cryptoFunctionService,
);
} else if (platform === "darwin") {
// eslint-disable-next-line
@@ -43,9 +39,8 @@ export class MainBiometricsService extends DesktopBiometricsService {
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService, this.logService);
} else if (platform === "linux") {
// eslint-disable-next-line
const OsBiometricsServiceLinux = require("./os-biometrics-linux-v2.service").default;
this.osBiometricsService = new OsBiometricsServiceLinux(
);
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
this.osBiometricsService = new OsBiometricsServiceLinux();
} else {
throw new Error("Unsupported platform");
}

View File

@@ -1,109 +0,0 @@
import { spawn } from "child_process";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
import { OsBiometricService } from "./os-biometrics.service";
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="com.bitwarden.Bitwarden.unlock">
<description>Unlock Bitwarden</description>
<message>Authenticate to unlock Bitwarden</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self</allow_active>
</defaults>
</action>
</policyconfig>`;
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
export default class OsBiometricsServiceLinux implements OsBiometricService {
private biometricsSystem = biometrics_v2.initBiometricSystem();
constructor() {}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from(""));
return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null;
}
async authenticateBiometric(): Promise<boolean> {
return await biometrics_v2.authenticate(
this.biometricsSystem,
Buffer.from(""),
"Authenticate to unlock",
);
}
async supportsBiometrics(): Promise<boolean> {
// We assume all linux distros have some polkit implementation
// that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup.
// Snap does not have access at the moment to polkit
// This could be dynamically detected on dbus in the future.
// We should check if a libsecret implementation is available on the system
// because otherwise we cannot offlod the protected userkey to secure storage.
return await passwords.isAvailable();
}
async needsSetup(): Promise<boolean> {
if (isSnapStore()) {
return false;
}
// check whether the polkit policy is loaded via dbus call to polkit
return !(await biometrics.available());
}
async canAutoSetup(): Promise<boolean> {
// We cannot auto setup on snap or flatpak since the filesystem is sandboxed.
// The user needs to manually set up the polkit policy outside of the sandbox
// since we allow access to polkit via dbus for the sandboxed clients, the authentication works from
// the sandbox, once the policy is set up outside of the sandbox.
return isLinux() && !isSnapStore() && !isFlatpak();
}
async runSetup(): Promise<void> {
const process = spawn("pkexec", [
"bash",
"-c",
`echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`,
]);
await new Promise((resolve, reject) => {
process.on("close", (code) => {
if (code !== 0) {
reject("Failed to set up polkit policy");
} else {
resolve(null);
}
});
});
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
}

View File

@@ -1,86 +0,0 @@
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { passwords } from "@bitwarden/desktop-napi";
import { BiometricStateService } from "@bitwarden/key-management";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics: {
setBiometricSecret: jest.fn(),
getBiometricSecret: jest.fn(),
deleteBiometricSecret: jest.fn(),
prompt: jest.fn(),
available: jest.fn(),
deriveKeyMaterial: jest.fn(),
},
passwords: {
deletePassword: jest.fn(),
getPassword: jest.fn(),
isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found",
},
}));
describe("OsBiometricsServiceLinux", () => {
let service: OsBiometricsServiceLinux;
let logService: LogService;
const mockUserId = "test-user-id" as UserId;
beforeEach(() => {
const biometricStateService = mock<BiometricStateService>();
const encryptService = mock<EncryptService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
logService = mock<LogService>();
service = new OsBiometricsServiceLinux(
biometricStateService,
encryptService,
cryptoFunctionService,
logService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("deleteBiometricKey", () => {
const serviceName = "Bitwarden_biometric";
const keyName = "test-user-id_user_biometric";
it("should delete biometric key successfully", async () => {
await service.deleteBiometricKey(mockUserId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
it("should not throw error if key not found", async () => {
passwords.deletePassword = jest
.fn()
.mockRejectedValueOnce(new Error(passwords.PASSWORD_NOT_FOUND));
await service.deleteBiometricKey(mockUserId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.",
keyName,
serviceName,
);
});
it("should throw error for unexpected errors", async () => {
const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockRejectedValueOnce(error);
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
});
});

View File

@@ -1,14 +1,9 @@
import { spawn } from "child_process";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { biometrics, biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
@@ -33,89 +28,32 @@ const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
export default class OsBiometricsServiceLinux implements OsBiometricService {
constructor(
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
private logService: LogService,
) {}
private biometricsSystem = biometrics_v2.initBiometricSystem();
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array | null>();
constructor() {}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalf ? Utils.fromBufferToB64(clientKeyHalf) : undefined,
});
await biometrics.setBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
key.toBase64(),
storageDetails.key_material,
storageDetails.ivB64,
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric key %s not found for service %s.",
getLookupKeyForUser(userId),
SERVICE,
);
} else {
throw e;
}
}
}
async deleteBiometricKey(userId: UserId): Promise<void> {}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (value == null || value == "") {
return null;
} else {
let clientKeyPartB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyPartB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
}
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyPartB64 ?? undefined,
});
const storedValue = await biometrics.getBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
storageDetails.key_material,
);
return SymmetricCryptoKey.fromString(storedValue);
}
const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from(""));
return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null;
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = Buffer.from("");
return await biometrics.prompt(hwnd, "");
return await biometrics_v2.authenticate(
this.biometricsSystem,
Buffer.from(""),
"Authenticate to unlock",
);
}
async supportsBiometrics(): Promise<boolean> {
@@ -163,69 +101,9 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
});
}
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
// when we want to force a re-derive of the key material.
private setIv(iv?: string) {
this._iv = iv ?? null;
this._osKeyHalf = null;
}
private async getStorageDetails({
clientKeyHalfB64,
}: {
clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
if (this._iv == null) {
throw new Error("Initialization Vector is null");
}
return {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
}
private async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<Uint8Array | null> {
if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId) || null;
}
// Retrieve existing key half if it exists
let clientKeyHalf: Uint8Array | null = null;
const encryptedClientKeyHalf =
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
if (encryptedClientKeyHalf != null) {
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
this.clientKeyHalves.set(userId, clientKeyHalf);
return clientKeyHalf;
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
if (this.clientKeyHalves.has(userId)) {
return BiometricsStatus.Available;
} else {
return BiometricsStatus.UnlockNeeded;
}
return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
}

View File

@@ -1,378 +0,0 @@
import { randomBytes } from "node:crypto";
import { BrowserWindow } from "electron";
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
import OsDerivedKey = biometrics.OsDerivedKey;
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: {
available: jest.fn().mockResolvedValue(true),
getBiometricSecret: jest.fn().mockResolvedValue(""),
setBiometricSecret: jest.fn().mockResolvedValue(""),
deleteBiometricSecret: jest.fn(),
deriveKeyMaterial: jest.fn().mockResolvedValue({
keyB64: "",
ivB64: "",
}),
prompt: jest.fn().mockResolvedValue(true),
},
passwords: {
getPassword: jest.fn().mockResolvedValue(null),
deletePassword: jest.fn().mockImplementation(() => {}),
isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found",
},
};
});
describe("OsBiometricsServiceWindows", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const browserWindow = mock<BrowserWindow>();
const encryptionService: EncryptService = mock<EncryptService>();
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
const logService = mock<LogService>();
let service: OsBiometricsServiceWindows;
const key = new SymmetricCryptoKey(new Uint8Array(64));
const userId = "test-user-id" as UserId;
const serviceKey = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
beforeEach(() => {
windowMain.win = browserWindow;
service = new OsBiometricsServiceWindows(
i18nService,
windowMain,
logService,
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getBiometricsFirstUnlockStatusForUser", () => {
const userId = "test-user-id" as UserId;
it("should return Available when client key half is set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return UnlockNeeded when client key half is not set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.UnlockNeeded);
});
});
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
it("should return cached key half if already present", async () => {
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBe(cachedKeyHalf);
});
it("should decrypt and return existing encrypted client key half", async () => {
biometricStateService.getEncryptedClientKeyHalf = jest
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3]));
const decrypted = new Uint8Array([4, 5, 6]);
encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId);
expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key);
expect(result).toEqual(decrypted);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted);
});
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
const randomBytes = new Uint8Array([7, 8, 9]);
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
const encrypted = new Uint8Array([10, 11, 12]);
encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted);
biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key);
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
encrypted,
userId,
);
expect(result).toEqual(randomBytes);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes);
});
});
describe("supportsBiometrics", () => {
it("should return true if biometrics are available", async () => {
biometrics.available = jest.fn().mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should return false if biometrics are not available", async () => {
biometrics.available = jest.fn().mockResolvedValue(false);
const result = await service.supportsBiometrics();
expect(result).toBe(false);
});
});
describe("getBiometricKey", () => {
beforeEach(() => {
biometrics.prompt = jest.fn().mockResolvedValue(true);
});
it("should return null when unsuccessfully authenticated biometrics", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
});
it.each([null, undefined, ""])(
"should throw error when no biometric key is found '%s'",
async (password) => {
passwords.getPassword = jest.fn().mockResolvedValue(password);
await expect(service.getBiometricKey(userId)).rejects.toThrow(
"Biometric key not found for user",
);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
},
);
it.each([[false], [true]])(
"should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
passwords.getPassword = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).toHaveBeenCalledWith(
serviceKey,
storageKey,
biometricKey,
{
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
},
"testIvB64",
);
},
);
it.each([[false], [true]])(
"should return the biometricKey if password is encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
const biometricKeyEncrypted = "2.testId|data|mac";
passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted);
biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).not.toHaveBeenCalled();
expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, {
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
});
},
);
});
describe("deleteBiometricKey", () => {
const serviceName = "Bitwarden_biometric";
const keyName = "test-user-id_user_biometric";
it("should delete biometric key successfully", async () => {
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
if (!keyFound) {
passwords.deletePassword = jest
.fn()
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
}
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
if (!keyFound) {
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.",
keyName,
serviceName,
);
}
});
it("should throw error when deletePassword for key throws unexpected errors", async () => {
const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockRejectedValue(error);
await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
});
describe("authenticateBiometric", () => {
const hwnd = randomBytes(32).buffer;
const consentMessage = "Test Windows Hello Consent Message";
beforeEach(() => {
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
i18nService.t.mockReturnValue(consentMessage);
});
it("should return true when biometric authentication is successful", async () => {
const result = await service.authenticateBiometric();
expect(result).toBe(true);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
it("should return false when biometric authentication fails", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.authenticateBiometric();
expect(result).toBe(false);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
});
describe("getStorageDetails", () => {
it.each([
["testClientKeyHalfB64", "testIvB64"],
[undefined, "testIvB64"],
["testClientKeyHalfB64", null],
[undefined, null],
])(
"should derive key material and ivB64 and return it when os key half not saved yet",
async (clientKeyHalfB64, ivB64) => {
service["setIv"](ivB64);
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: "derivedIvB64",
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
const result = await service["getStorageDetails"]({ clientKeyHalfB64 });
expect(result).toEqual({
key_material: {
osKeyPartB64: derivedKeyMaterial.keyB64,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: derivedKeyMaterial.ivB64,
});
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64);
expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64);
expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64);
},
);
it("should throw an error when deriving key material and returned iv is null", async () => {
service["setIv"]("testIvB64");
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: null as string | undefined | null,
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
await expect(
service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }),
).rejects.toThrow("Initialization Vector is null");
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64");
});
});
describe("setIv", () => {
it("should set the iv and reset the osKeyHalf", () => {
const iv = "testIv";
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](iv);
expect(service["_iv"]).toBe(iv);
expect(service["_osKeyHalf"]).toBeNull();
});
it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => {
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](undefined);
expect(service["_iv"]).toBeNull();
expect(service["_osKeyHalf"]).toBeNull();
});
});
});

View File

@@ -17,7 +17,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
) {}
async supportsBiometrics(): Promise<boolean> {
return await biometrics.available();
return await biometrics_v2.authenticateAvailable(this.biometricsSystem);
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
@@ -61,6 +61,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
async runSetup(): Promise<void> {}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return BiometricsStatus.Available;
return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId) || await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) ? BiometricsStatus.Available : BiometricsStatus.UnlockNeeded;
}
}