1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-04 17:43:39 +00:00

Rework Desktop Biometrics (#5234)

This commit is contained in:
Matt Gibson
2023-04-18 09:09:47 -04:00
committed by GitHub
parent 4852992662
commit 830af7b06d
55 changed files with 2497 additions and 564 deletions

View File

@@ -1,25 +1,21 @@
import { ipcMain, systemPreferences } from "electron";
import { systemPreferences } from "electron";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { passwords } from "@bitwarden/desktop-native";
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
import { OsBiometricService } from "./biometrics.service.abstraction";
export default class BiometricDarwinMain implements BiometricsServiceAbstraction {
export default class BiometricDarwinMain implements OsBiometricService {
constructor(private i18nservice: I18nService, private stateService: StateService) {}
async init() {
await this.stateService.setEnableBiometric(await this.supportsBiometric());
await this.stateService.setBiometricText("unlockWithTouchId");
await this.stateService.setNoAutoPromptBiometricsText("autoPromptTouchId");
ipcMain.handle("biometric", async () => {
return await this.authenticateBiometric();
});
}
supportsBiometric(): Promise<boolean> {
return Promise.resolve(systemPreferences.canPromptTouchID());
async osSupportsBiometric(): Promise<boolean> {
return systemPreferences.canPromptTouchID();
}
async authenticateBiometric(): Promise<boolean> {
@@ -30,4 +26,35 @@ export default class BiometricDarwinMain implements BiometricsServiceAbstraction
return false;
}
}
async getBiometricKey(service: string, key: string): Promise<string | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
return await passwords.getPassword(service, key);
}
async setBiometricKey(service: string, key: string, value: string): Promise<void> {
if (await this.valueUpToDate(service, key, value)) {
return;
}
return await passwords.setPassword(service, key, value);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
return await passwords.deletePassword(service, key);
}
private async valueUpToDate(service: string, key: string, value: string): Promise<boolean> {
try {
const existing = await passwords.getPassword(service, key);
return existing === value;
} catch {
return false;
}
}
}

View File

@@ -1,48 +1,224 @@
import { ipcMain } from "electron";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { biometrics } from "@bitwarden/desktop-native";
import { EncString } from "@bitwarden/common/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { biometrics, passwords } from "@bitwarden/desktop-native";
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
import { WindowMain } from "../window.main";
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
import { OsBiometricService } from "./biometrics.service.abstraction";
const KEY_WITNESS_SUFFIX = "_witness";
const WITNESS_VALUE = "known key";
export default class BiometricWindowsMain implements OsBiometricService {
// Use set helper method instead of direct access
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
export default class BiometricWindowsMain implements BiometricsServiceAbstraction {
constructor(
private i18nservice: I18nService,
private i18nService: I18nService,
private windowMain: WindowMain,
private stateService: StateService,
private stateService: ElectronStateService,
private logService: LogService
) {}
async init() {
let supportsBiometric = false;
try {
supportsBiometric = await this.supportsBiometric();
} catch (e) {
this.logService.error(e);
}
await this.stateService.setEnableBiometric(supportsBiometric);
await this.stateService.setBiometricText("unlockWithWindowsHello");
await this.stateService.setNoAutoPromptBiometricsText("autoPromptWindowsHello");
ipcMain.handle("biometric", async () => {
return await this.authenticateBiometric();
});
}
async supportsBiometric(): Promise<boolean> {
try {
return await biometrics.available();
} catch {
return false;
async osSupportsBiometric(): Promise<boolean> {
return await biometrics.available();
}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyHalfB64: string
): Promise<string | null> {
const value = await passwords.getPassword(service, storageKey);
if (value == null || value == "") {
return null;
} else if (!EncString.isSerializedEncString(value)) {
// Update to format encrypted with client key half
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64,
});
await biometrics.setBiometricSecret(
service,
storageKey,
value,
storageDetails.key_material,
storageDetails.ivB64
);
return value;
} else {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64,
});
return await biometrics.getBiometricSecret(service, storageKey, storageDetails.key_material);
}
}
async setBiometricKey(
service: string,
storageKey: string,
value: string,
clientKeyPartB64: string | undefined
): Promise<void> {
const parsedValue = SymmetricCryptoKey.fromString(value);
if (await this.valueUpToDate({ value: parsedValue, clientKeyPartB64, service, storageKey })) {
return;
}
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storedValue = await biometrics.setBiometricSecret(
service,
storageKey,
value,
storageDetails.key_material,
storageDetails.ivB64
);
const parsedStoredValue = new EncString(storedValue);
await this.storeValueWitness(
parsedValue,
parsedStoredValue,
service,
storageKey,
clientKeyPartB64
);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
await passwords.deletePassword(service, key);
await passwords.deletePassword(service, key + KEY_WITNESS_SUFFIX);
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nservice.t("windowsHelloConsentMessage"));
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
}
private async getStorageDetails({
clientKeyHalfB64,
}: {
clientKeyHalfB64: string;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
// Prompts Windows Hello
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
return {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
}
// 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;
this._osKeyHalf = null;
}
/**
* Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date.
*
* @param unencryptedValue The key to store
* @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key.
* @param service The service to store the witness key under
* @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns
*/
private async storeValueWitness(
unencryptedValue: SymmetricCryptoKey,
encryptedValue: EncString,
service: string,
storageKey: string,
clientKeyPartB64: string
) {
if (encryptedValue.iv == null || encryptedValue == null) {
return;
}
const storageDetails = {
keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64),
ivB64: encryptedValue.iv,
};
await biometrics.setBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
WITNESS_VALUE,
storageDetails.keyMaterial,
storageDetails.ivB64
);
}
/**
* Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
* @param value The value being validated
* @param service The service the value is stored under
* @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns Boolean indicating if the value is up to date.
*/
// Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
private async valueUpToDate({
value,
clientKeyPartB64,
service,
storageKey,
}: {
value: SymmetricCryptoKey;
clientKeyPartB64: string;
service: string;
storageKey: string;
}): Promise<boolean> {
const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64);
if (witnessKeyMaterial == null) {
return false;
}
let witness = null;
try {
witness = await biometrics.getBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
witnessKeyMaterial
);
} catch {
this.logService.debug("Error retrieving witness key, assuming value is not up to date.");
return false;
}
if (witness === WITNESS_VALUE) {
return true;
}
return false;
}
/** Derives a witness key from a symmetric key being stored for biometric protection */
private witnessKeyMaterial(
symmetricKey: SymmetricCryptoKey,
clientKeyPartB64: string
): biometrics.KeyMaterial {
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
return {
osKeyPartB64: key,
clientKeyPartB64,
};
}
}

View File

@@ -1,5 +1,44 @@
export abstract class BiometricsServiceAbstraction {
init: () => Promise<void>;
supportsBiometric: () => Promise<boolean>;
osSupportsBiometric: () => Promise<boolean>;
canAuthBiometric: ({
service,
key,
userId,
}: {
service: string;
key: string;
userId: string;
}) => Promise<boolean>;
authenticateBiometric: () => Promise<boolean>;
getBiometricKey: (service: string, key: string) => Promise<string | null>;
setBiometricKey: (service: string, key: string, value: string) => Promise<void>;
setEncryptionKeyHalf: ({
service,
key,
value,
}: {
service: string;
key: string;
value: string;
}) => void;
deleteBiometricKey: (service: string, key: string) => Promise<void>;
}
export interface OsBiometricService {
init: () => Promise<void>;
osSupportsBiometric: () => Promise<boolean>;
authenticateBiometric: () => Promise<boolean>;
getBiometricKey: (
service: string,
key: string,
clientKeyHalfB64: string | undefined
) => Promise<string | null>;
setBiometricKey: (
service: string,
key: string,
value: string,
clientKeyHalfB64: string | undefined
) => Promise<void>;
deleteBiometricKey: (service: string, key: string) => Promise<void>;
}

View File

@@ -1,16 +1,16 @@
import { mock } from "jest-mock-extended";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
import { WindowMain } from "../window.main";
import BiometricDarwinMain from "./biometric.darwin.main";
import BiometricWindowsMain from "./biometric.windows.main";
import { BiometricsService } from "./biometrics.service";
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
import { OsBiometricService } from "./biometrics.service.abstraction";
jest.mock("@bitwarden/desktop-native", () => {
return {
@@ -22,11 +22,11 @@ jest.mock("@bitwarden/desktop-native", () => {
describe("biometrics tests", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const stateService = mock<StateService>();
const stateService = mock<ElectronStateService>();
const logService = mock<LogService>();
const messagingService = mock<MessagingService>();
it("Should call the platformspecific methods", () => {
it("Should call the platformspecific methods", async () => {
const sut = new BiometricsService(
i18nService,
windowMain,
@@ -36,13 +36,14 @@ describe("biometrics tests", function () {
process.platform
);
const mockService = mock<BiometricsServiceAbstraction>();
const mockService = mock<OsBiometricService>();
(sut as any).platformSpecificService = mockService;
sut.init();
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
expect(mockService.init).toBeCalled();
sut.supportsBiometric();
expect(mockService.supportsBiometric).toBeCalled();
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
expect(mockService.osSupportsBiometric).toBeCalled();
sut.authenticateBiometric();
expect(mockService.authenticateBiometric).toBeCalled();
@@ -78,4 +79,50 @@ describe("biometrics tests", function () {
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
});
});
describe("can auth biometric", () => {
let sut: BiometricsService;
let innerService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new BiometricsService(
i18nService,
windowMain,
stateService,
logService,
messagingService,
process.platform
);
innerService = mock();
(sut as any).platformSpecificService = innerService;
sut.init();
});
it("should return false if client key half is required and not provided", async () => {
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(true);
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
expect(result).toBe(false);
});
it("should call osSupportsBiometric if client key half is provided", async () => {
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
expect(innerService.init).toBeCalled();
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
expect(innerService.osSupportsBiometric).toBeCalled();
});
it("should call osSupportBiometric if client key half is not required", async () => {
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(false);
innerService.osSupportsBiometric.mockResolvedValue(true);
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
expect(result).toBe(true);
expect(innerService.osSupportsBiometric).toBeCalled();
});
});
});

View File

@@ -1,19 +1,20 @@
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
import { WindowMain } from "../window.main";
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
import { BiometricsServiceAbstraction, OsBiometricService } from "./biometrics.service.abstraction";
export class BiometricsService implements BiometricsServiceAbstraction {
private platformSpecificService: BiometricsServiceAbstraction;
private platformSpecificService: OsBiometricService;
private clientKeyHalves = new Map<string, string>();
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private stateService: StateService,
private stateService: ElectronStateService,
private logService: LogService,
private messagingService: MessagingService,
private platform: NodeJS.Platform
@@ -50,16 +51,121 @@ export class BiometricsService implements BiometricsServiceAbstraction {
return await this.platformSpecificService.init();
}
async supportsBiometric(): Promise<boolean> {
return await this.platformSpecificService.supportsBiometric();
async osSupportsBiometric() {
return await this.platformSpecificService.osSupportsBiometric();
}
async canAuthBiometric({
service,
key,
userId,
}: {
service: string;
key: string;
userId: string;
}): Promise<boolean> {
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart({
userId,
});
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
return clientKeyHalfSatisfied && (await this.osSupportsBiometric());
}
async authenticateBiometric(): Promise<boolean> {
let result = false;
this.interruptProcessReload(
() => {
return this.platformSpecificService.authenticateBiometric();
},
(response) => {
result = response;
return !response;
}
);
return result;
}
async getBiometricKey(service: string, storageKey: string): Promise<string | null> {
return await this.interruptProcessReload(async () => {
await this.enforceClientKeyHalf(service, storageKey);
return await this.platformSpecificService.getBiometricKey(
service,
storageKey,
this.getClientKeyHalf(service, storageKey)
);
});
}
async setBiometricKey(service: string, storageKey: string, value: string): Promise<void> {
await this.enforceClientKeyHalf(service, storageKey);
return await this.platformSpecificService.setBiometricKey(
service,
storageKey,
value,
this.getClientKeyHalf(service, storageKey)
);
}
/** Registers the client-side encryption key half for the OS stored Biometric key. The other half is protected by the OS.*/
async setEncryptionKeyHalf({
service,
key,
value,
}: {
service: string;
key: string;
value: string;
}): Promise<void> {
if (value == null) {
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, key));
} else {
this.clientKeyHalves.set(this.clientKeyHalfKey(service, key), value);
}
}
async deleteBiometricKey(service: string, storageKey: string): Promise<void> {
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, storageKey));
return await this.platformSpecificService.deleteBiometricKey(service, storageKey);
}
private async interruptProcessReload<T>(
callback: () => Promise<T>,
restartReloadCallback: (arg: T) => boolean = () => false
): Promise<T> {
this.messagingService.send("cancelProcessReload");
const response = await this.platformSpecificService.authenticateBiometric();
if (!response) {
let restartReload = false;
let response: T;
try {
response = await callback();
restartReload ||= restartReloadCallback(response);
} catch {
restartReload = true;
}
if (restartReload) {
this.messagingService.send("startProcessReload");
}
return response;
}
private clientKeyHalfKey(service: string, key: string): string {
return `${service}:${key}`;
}
private getClientKeyHalf(service: string, key: string): string | undefined {
return this.clientKeyHalves.get(this.clientKeyHalfKey(service, key)) ?? undefined;
}
private async enforceClientKeyHalf(service: string, storageKey: string): Promise<void> {
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart();
const clientKeyHalfB64 = this.getClientKeyHalf(service, storageKey);
if (requireClientKeyHalf && !clientKeyHalfB64) {
throw new Error("Biometric key requirements not met. No client key half provided.");
}
}
}

View File

@@ -1,16 +1,20 @@
import { ipcMain } from "electron";
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
import { passwords } from "@bitwarden/desktop-native";
import { BiometricMessage, BiometricStorageAction } from "../types/biometric-message";
import { BiometricsServiceAbstraction } from "./biometric/index";
const AuthRequiredSuffix = "_biometric";
const AuthenticatedActions = ["getPassword"];
export class DesktopCredentialStorageListener {
constructor(
private serviceName: string,
private biometricService: BiometricsServiceAbstraction
private biometricService: BiometricsServiceAbstraction,
private logService: ConsoleLogService
) {}
init() {
@@ -22,46 +26,107 @@ export class DesktopCredentialStorageListener {
serviceName += message.keySuffix;
}
const authenticationRequired =
AuthenticatedActions.includes(message.action) && AuthRequiredSuffix === message.keySuffix;
const authenticated = !authenticationRequired || (await this.authenticateBiometric());
let val: string | boolean = null;
if (authenticated && message.action && message.key) {
if (message.action && message.key) {
if (message.action === "getPassword") {
val = await this.getPassword(serviceName, message.key);
val = await this.getPassword(serviceName, message.key, message.keySuffix);
} else if (message.action === "hasPassword") {
const result = await this.getPassword(serviceName, message.key);
const result = await passwords.getPassword(serviceName, message.key);
val = result != null;
} else if (message.action === "setPassword" && message.value) {
await passwords.setPassword(serviceName, message.key, message.value);
await this.setPassword(serviceName, message.key, message.value, message.keySuffix);
} else if (message.action === "deletePassword") {
await passwords.deletePassword(serviceName, message.key);
await this.deletePassword(serviceName, message.key, message.keySuffix);
}
}
return val;
} catch {
return null;
} catch (e) {
if (
e.message === "Password not found." ||
e.message === "The specified item could not be found in the keychain."
) {
return null;
}
this.logService.info(e);
}
});
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
try {
let serviceName = this.serviceName;
message.keySuffix = "_" + (message.keySuffix ?? "");
if (message.keySuffix !== "_") {
serviceName += message.keySuffix;
}
let val: string | boolean = null;
if (!message.action) {
return val;
}
switch (message.action) {
case BiometricStorageAction.EnabledForUser:
if (!message.key || !message.userId) {
break;
}
val = await this.biometricService.canAuthBiometric({
service: serviceName,
key: message.key,
userId: message.userId,
});
break;
case BiometricStorageAction.OsSupported:
val = await this.biometricService.osSupportsBiometric();
break;
default:
}
return val;
} catch (e) {
this.logService.info(e);
}
});
}
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
private async getPassword(serviceName: string, key: string) {
let val = await passwords.getPassword(serviceName, key);
private async getPassword(serviceName: string, key: string, keySuffix: string) {
let val: string;
if (keySuffix === AuthRequiredSuffix) {
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
} else {
val = await passwords.getPassword(serviceName, key);
}
try {
JSON.parse(val);
} catch (e) {
val = await passwords.getPasswordKeytar(serviceName, key);
await passwords.setPassword(serviceName, key, val);
throw new Error("Password in bad format" + e + val);
}
return val;
}
private async authenticateBiometric(): Promise<boolean> {
if (this.biometricService) {
return await this.biometricService.authenticateBiometric();
private async setPassword(serviceName: string, key: string, value: string, keySuffix: string) {
if (keySuffix === AuthRequiredSuffix) {
const valueObj = JSON.parse(value) as BiometricKey;
await this.biometricService.setEncryptionKeyHalf({
service: serviceName,
key,
value: valueObj?.clientEncKeyHalf,
});
// Value is usually a JSON string, but we need to pass the key half as well, so we re-stringify key here.
await this.biometricService.setBiometricKey(serviceName, key, JSON.stringify(valueObj?.key));
} else {
await passwords.setPassword(serviceName, key, value);
}
}
private async deletePassword(serviceName: string, key: string, keySuffix: string) {
if (keySuffix === AuthRequiredSuffix) {
await this.biometricService.deleteBiometricKey(serviceName, key);
} else {
await passwords.deletePassword(serviceName, key);
}
return false;
}
}