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

[PM-10741] Refactor biometrics interface & add dynamic status (#10973)

This commit is contained in:
Bernd Schoolmann
2025-01-08 10:46:00 +01:00
committed by GitHub
parent 0bd988dac8
commit 72121cda94
66 changed files with 1840 additions and 1459 deletions

View File

@@ -4656,6 +4656,33 @@
"noEditPermissions": {
"message": "You don't have permission to edit this item"
},
"biometricsStatusHelptextUnlockNeeded": {
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
},
"biometricsStatusHelptextHardwareUnavailable": {
"message": "Biometric unlock is currently unavailable."
},
"biometricsStatusHelptextAutoSetupNeeded": {
"message": "Biometric unlock is unavailable due to misconfigured system files."
},
"biometricsStatusHelptextManualSetupNeeded": {
"message": "Biometric unlock is unavailable due to misconfigured system files."
},
"biometricsStatusHelptextDesktopDisconnected": {
"message": "Biometric unlock is unavailable because the Bitwarden desktop app is closed."
},
"biometricsStatusHelptextNotEnabledInDesktop": {
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
"placeholders": {
"email": {
"content": "$1",
"example": "mail@example.com"
}
}
},
"biometricsStatusHelptextUnavailableReasonUnknown": {
"message": "Biometric unlock is currently unavailable for an unknown reason."
},
"authenticating": {
"message": "Authenticating"
},

View File

@@ -8,6 +8,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AvatarModule, ItemModule } from "@bitwarden/components";
import { BiometricsService } from "@bitwarden/key-management";
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
@@ -26,6 +27,7 @@ export class AccountComponent {
private location: Location,
private i18nService: I18nService,
private logService: LogService,
private biometricsService: BiometricsService,
) {}
get specialAccountAddId() {
@@ -45,6 +47,9 @@ export class AccountComponent {
// locked or logged out account statuses are handled by background and app.component
if (result?.status === AuthenticationStatus.Unlocked) {
this.location.back();
await this.biometricsService.setShouldAutopromptNow(false);
} else {
await this.biometricsService.setShouldAutopromptNow(true);
}
this.loading.emit(false);
}

View File

@@ -11,13 +11,16 @@
<h2 bitTypography="h6">{{ "unlockMethods" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-control *ngIf="supportsBiometric">
<bit-form-control>
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
<bit-label for="biometric" class="tw-whitespace-normal">{{
"unlockWithBiometrics" | i18n
}}</bit-label>
<bit-hint *ngIf="biometricUnavailabilityReason">
{{ biometricUnavailabilityReason }}
</bit-hint>
</bit-form-control>
<bit-form-control class="tw-pl-5" *ngIf="supportsBiometric && this.form.value.biometric">
<bit-form-control class="tw-pl-5" *ngIf="this.form.value.biometric">
<input
bitCheckbox
id="autoBiometricsPrompt"

View File

@@ -17,6 +17,7 @@ import {
Subject,
switchMap,
takeUntil,
timer,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -53,7 +54,12 @@ import {
TypographyModule,
ToastService,
} from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
import {
KeyService,
BiometricsService,
BiometricStateService,
BiometricsStatus,
} from "@bitwarden/key-management";
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -99,7 +105,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
availableVaultTimeoutActions: VaultTimeoutAction[] = [];
vaultTimeoutOptions: VaultTimeoutOption[] = [];
hasVaultTimeoutPolicy = false;
supportsBiometric: boolean;
biometricUnavailabilityReason: string;
showChangeMasterPass = true;
accountSwitcherEnabled = false;
@@ -199,7 +205,40 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
};
this.form.patchValue(initialValues, { emitEvent: false });
this.supportsBiometric = await this.biometricsService.supportsBiometric();
timer(0, 1000)
.pipe(
switchMap(async () => {
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
const biometricSettingAvailable =
(status !== BiometricsStatus.DesktopDisconnected &&
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) ||
(await this.vaultTimeoutSettingsService.isBiometricLockSet());
if (!biometricSettingAvailable) {
this.form.controls.biometric.disable({ emitEvent: false });
} else {
this.form.controls.biometric.enable({ emitEvent: false });
}
if (status === BiometricsStatus.DesktopDisconnected && !biometricSettingAvailable) {
this.biometricUnavailabilityReason = this.i18nService.t(
"biometricsStatusHelptextDesktopDisconnected",
);
} else if (
status === BiometricsStatus.NotEnabledInConnectedDesktopApp &&
!biometricSettingAvailable
) {
this.biometricUnavailabilityReason = this.i18nService.t(
"biometricsStatusHelptextNotEnabledInDesktop",
activeAccount.email,
);
} else {
this.biometricUnavailabilityReason = "";
}
}),
takeUntil(this.destroy$),
)
.subscribe();
this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword();
this.form.controls.vaultTimeout.valueChanges
@@ -399,7 +438,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
}
async updateBiometric(enabled: boolean) {
if (enabled && this.supportsBiometric) {
if (enabled) {
let granted;
try {
granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] });
@@ -471,7 +510,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
const biometricsPromise = async () => {
try {
const result = await this.biometricsService.authenticateBiometric();
const result = await this.biometricsService.authenticateWithBiometrics();
// prevent duplicate dialog
biometricsResponseReceived = true;

View File

@@ -204,6 +204,7 @@ import {
BiometricStateService,
BiometricsService,
DefaultBiometricStateService,
DefaultKeyService,
DefaultKdfConfigService,
KdfConfigService,
KeyService as KeyServiceAbstraction,
@@ -241,7 +242,6 @@ import AutofillService from "../autofill/services/autofill.service";
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
import { SafariApp } from "../browser/safariApp";
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
import { BrowserKeyService } from "../key-management/browser-key.service";
import { BrowserApi } from "../platform/browser/browser-api";
import { flagEnabled } from "../platform/flags";
import { UpdateBadge } from "../platform/listeners/update-badge";
@@ -416,6 +416,7 @@ export default class MainBackground {
await this.refreshMenu(true);
if (this.systemService != null) {
await this.systemService.clearPendingClipboard();
await this.biometricsService.setShouldAutopromptNow(false);
await this.processReloadService.startProcessReload(this.authService);
}
};
@@ -633,6 +634,7 @@ export default class MainBackground {
this.biometricsService = new BackgroundBrowserBiometricsService(
runtimeNativeMessagingBackground,
this.logService,
);
this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider);
@@ -649,7 +651,7 @@ export default class MainBackground {
this.stateService,
);
this.keyService = new BrowserKeyService(
this.keyService = new DefaultKeyService(
this.pinService,
this.masterPasswordService,
this.keyGenerationService,
@@ -660,8 +662,6 @@ export default class MainBackground {
this.stateService,
this.accountService,
this.stateProvider,
this.biometricStateService,
this.biometricsService,
this.kdfConfigService,
);
@@ -857,10 +857,8 @@ export default class MainBackground {
this.userVerificationApiService,
this.userDecryptionOptionsService,
this.pinService,
this.logService,
this.vaultTimeoutSettingsService,
this.platformUtilsService,
this.kdfConfigService,
this.biometricsService,
);
this.vaultFilterService = new VaultFilterService(
@@ -890,6 +888,7 @@ export default class MainBackground {
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
this.biometricsService,
lockedCallback,
logoutCallback,
);
@@ -1081,6 +1080,7 @@ export default class MainBackground {
this.vaultTimeoutSettingsService,
this.biometricStateService,
this.accountService,
this.logService,
);
// Other fields

View File

@@ -1,10 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@@ -14,18 +13,19 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/types/key";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
import { KeyService, BiometricStateService, BiometricsCommands } from "@bitwarden/key-management";
import { BrowserApi } from "../platform/browser/browser-api";
import RuntimeBackground from "./runtime.background";
const MessageValidTimeout = 10 * 1000;
const MessageNoResponseTimeout = 60 * 1000;
const HashAlgorithmForEncryption = "sha1";
type Message = {
command: string;
messageId?: number;
// Filled in by this service
userId?: string;
@@ -43,6 +43,7 @@ type OuterMessage = {
type ReceiveMessage = {
timestamp: number;
command: string;
messageId: number;
response?: any;
// Unlock key
@@ -53,19 +54,23 @@ type ReceiveMessage = {
type ReceiveMessageOuter = {
command: string;
appId: string;
messageId?: number;
// Should only have one of these.
message?: EncString;
sharedSecret?: string;
};
type Callback = {
resolver: any;
rejecter: any;
};
export class NativeMessagingBackground {
private connected = false;
connected = false;
private connecting: boolean;
private port: browser.runtime.Port | chrome.runtime.Port;
private resolver: any = null;
private rejecter: any = null;
private privateKey: Uint8Array = null;
private publicKey: Uint8Array = null;
private secureSetupResolve: any = null;
@@ -73,6 +78,11 @@ export class NativeMessagingBackground {
private appId: string;
private validatingFingerprint: boolean;
private messageId = 0;
private callbacks = new Map<number, Callback>();
isConnectedToOutdatedDesktopClient = true;
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
@@ -97,6 +107,7 @@ export class NativeMessagingBackground {
}
async connect() {
this.logService.info("[Native Messaging IPC] Connecting to Bitwarden Desktop app...");
this.appId = await this.appIdService.getAppId();
await this.biometricStateService.setFingerprintValidated(false);
@@ -106,6 +117,9 @@ export class NativeMessagingBackground {
this.connecting = true;
const connectedCallback = () => {
this.logService.info(
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
);
this.connected = true;
this.connecting = false;
resolve();
@@ -123,11 +137,17 @@ export class NativeMessagingBackground {
connectedCallback();
break;
case "disconnected":
this.logService.info("[Native Messaging IPC] Disconnected from Bitwarden Desktop app.");
if (this.connecting) {
reject(new Error("startDesktop"));
}
this.connected = false;
this.port.disconnect();
// reject all
for (const callback of this.callbacks.values()) {
callback.rejecter("disconnected");
}
this.callbacks.clear();
break;
case "setupEncryption": {
// Ignore since it belongs to another device
@@ -147,6 +167,16 @@ export class NativeMessagingBackground {
await this.biometricStateService.setFingerprintValidated(true);
}
this.sharedSecret = new SymmetricCryptoKey(decrypted);
this.logService.info("[Native Messaging IPC] Secure channel established");
if ("messageId" in message) {
this.logService.info("[Native Messaging IPC] Non-legacy desktop client");
this.isConnectedToOutdatedDesktopClient = false;
} else {
this.logService.info("[Native Messaging IPC] Legacy desktop client");
this.isConnectedToOutdatedDesktopClient = true;
}
this.secureSetupResolve();
break;
}
@@ -155,17 +185,25 @@ export class NativeMessagingBackground {
if (message.appId !== this.appId) {
return;
}
this.logService.warning(
"[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...",
);
this.sharedSecret = null;
this.privateKey = null;
this.connected = false;
this.rejecter({
message: "invalidateEncryption",
});
if (this.callbacks.has(message.messageId)) {
this.callbacks.get(message.messageId).rejecter({
message: "invalidateEncryption",
});
}
return;
case "verifyFingerprint": {
if (this.sharedSecret == null) {
this.logService.info(
"[Native Messaging IPC] Desktop app requested trust verification by fingerprint.",
);
this.validatingFingerprint = true;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -174,9 +212,11 @@ export class NativeMessagingBackground {
break;
}
case "wrongUserId":
this.rejecter({
message: "wrongUserId",
});
if (this.callbacks.has(message.messageId)) {
this.callbacks.get(message.messageId).rejecter({
message: "wrongUserId",
});
}
return;
default:
// Ignore since it belongs to another device
@@ -210,6 +250,60 @@ export class NativeMessagingBackground {
});
}
async callCommand(message: Message): Promise<any> {
const messageId = this.messageId++;
if (
message.command == BiometricsCommands.Unlock ||
message.command == BiometricsCommands.IsAvailable
) {
// TODO remove after 2025.01
// wait until there is no other callbacks, or timeout
const call = await firstValueFrom(
race(
from([false]).pipe(delay(5000)),
timer(0, 100).pipe(
filter(() => this.callbacks.size === 0),
map(() => true),
),
),
);
if (!call) {
this.logService.info(
`[Native Messaging IPC] Message of type ${message.command} did not get a response before timing out`,
);
return;
}
}
const callback = new Promise((resolver, rejecter) => {
this.callbacks.set(messageId, { resolver, rejecter });
});
message.messageId = messageId;
try {
await this.send(message);
} catch (e) {
this.logService.info(
`[Native Messaging IPC] Error sending message of type ${message.command} to Bitwarden Desktop app. Error: ${e}`,
);
const callback = this.callbacks.get(messageId);
this.callbacks.delete(messageId);
callback.rejecter("errorConnecting");
}
setTimeout(() => {
if (this.callbacks.has(messageId)) {
this.logService.info("[Native Messaging IPC] Message timed out and received no response");
this.callbacks.get(messageId).rejecter({
message: "timeout",
});
this.callbacks.delete(messageId);
}
}, MessageNoResponseTimeout);
return callback;
}
async send(message: Message) {
if (!this.connected) {
await this.connect();
@@ -233,20 +327,7 @@ export class NativeMessagingBackground {
return await this.encryptService.encrypt(JSON.stringify(message), this.sharedSecret);
}
getResponse(): Promise<any> {
return new Promise((resolve, reject) => {
this.resolver = function (response: any) {
resolve(response);
};
this.rejecter = function (resp: any) {
reject({
message: resp,
});
};
});
}
private postMessage(message: OuterMessage) {
private postMessage(message: OuterMessage, messageId?: number) {
// Wrap in try-catch to when the port disconnected without triggering `onDisconnect`.
try {
const msg: any = message;
@@ -262,13 +343,17 @@ export class NativeMessagingBackground {
}
this.port.postMessage(msg);
} catch (e) {
this.logService.error("NativeMessaging port disconnected, disconnecting.");
this.logService.info(
"[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.",
);
this.sharedSecret = null;
this.privateKey = null;
this.connected = false;
this.rejecter("invalidateEncryption");
if (this.callbacks.has(messageId)) {
this.callbacks.get(messageId).rejecter("invalidateEncryption");
}
}
}
@@ -285,90 +370,30 @@ export class NativeMessagingBackground {
}
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
this.logService.error("NativeMessage is to old, ignoring.");
this.logService.info("[Native Messaging IPC] Received an old native message, ignoring...");
return;
}
switch (message.command) {
case "biometricUnlock": {
if (
["not available", "not enabled", "not supported", "not unlocked", "canceled"].includes(
message.response,
)
) {
this.rejecter(message.response);
return;
}
const messageId = message.messageId;
// Check for initial setup of biometric unlock
const enabled = await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$);
if (enabled === null || enabled === false) {
if (message.response === "unlocked") {
await this.biometricStateService.setBiometricUnlockEnabled(true);
}
break;
}
// Ignore unlock if already unlocked
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.Unlocked) {
break;
}
if (message.response === "unlocked") {
try {
if (message.userKeyB64) {
const userKey = new SymmetricCryptoKey(
Utils.fromB64ToArray(message.userKeyB64),
) as UserKey;
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const isUserKeyValid = await this.keyService.validateUserKey(userKey, activeUserId);
if (isUserKeyValid) {
await this.keyService.setUserKey(userKey, activeUserId);
} else {
this.logService.error("Unable to verify biometric unlocked userkey");
await this.keyService.clearKeys(activeUserId);
this.rejecter("userkey wrong");
return;
}
} else {
throw new Error("No key received");
}
} catch (e) {
this.logService.error("Unable to set key: " + e);
this.rejecter("userkey wrong");
return;
}
// Verify key is correct by attempting to decrypt a secret
try {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.keyService.getFingerprint(userId);
} catch (e) {
this.logService.error("Unable to verify key: " + e);
await this.keyService.clearKeys();
this.rejecter("userkey wrong");
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.runtimeBackground.processMessage({ command: "unlocked" });
}
break;
}
case "biometricUnlockAvailable": {
this.resolver(message);
break;
}
default:
this.logService.error("NativeMessage, got unknown command: " + message.command);
break;
if (
message.command == BiometricsCommands.Unlock ||
message.command == BiometricsCommands.IsAvailable
) {
this.logService.info(
`[Native Messaging IPC] Received legacy message of type ${message.command}`,
);
const messageId = this.callbacks.keys().next().value;
const resolver = this.callbacks.get(messageId);
this.callbacks.delete(messageId);
resolver.resolver(message);
return;
}
if (this.resolver) {
this.resolver(message);
if (this.callbacks.has(messageId)) {
this.callbacks.get(messageId).resolver(message);
} else {
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
}
}
@@ -384,6 +409,7 @@ export class NativeMessagingBackground {
command: "setupEncryption",
publicKey: Utils.fromBufferToB64(publicKey),
userId: userId,
messageId: this.messageId++,
});
return new Promise((resolve, reject) => (this.secureSetupResolve = resolve));

View File

@@ -16,6 +16,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BiometricsCommands } from "@bitwarden/key-management";
import { MessageListener, isExternalMessage } from "../../../../libs/common/src/platform/messaging";
import {
@@ -71,8 +72,10 @@ export default class RuntimeBackground {
sendResponse: (response: any) => void,
) => {
const messagesWithResponse = [
"biometricUnlock",
"biometricUnlockAvailable",
BiometricsCommands.AuthenticateWithBiometrics,
BiometricsCommands.GetBiometricsStatus,
BiometricsCommands.UnlockWithBiometricsForUser,
BiometricsCommands.GetBiometricsStatusForUser,
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
"getInlineMenuFieldQualificationFeatureFlag",
"getInlineMenuTotpFeatureFlag",
@@ -185,13 +188,17 @@ export default class RuntimeBackground {
break;
}
break;
case "biometricUnlock": {
const result = await this.main.biometricsService.authenticateBiometric();
return result;
case BiometricsCommands.AuthenticateWithBiometrics: {
return await this.main.biometricsService.authenticateWithBiometrics();
}
case "biometricUnlockAvailable": {
const result = await this.main.biometricsService.isBiometricUnlockAvailable();
return result;
case BiometricsCommands.GetBiometricsStatus: {
return await this.main.biometricsService.getBiometricsStatus();
}
case BiometricsCommands.UnlockWithBiometricsForUser: {
return await this.main.biometricsService.unlockWithBiometricsForUser(msg.userId);
}
case BiometricsCommands.GetBiometricsStatusForUser: {
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
}
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
return await this.configService.getFeatureFlag(

View File

@@ -1,36 +1,136 @@
import { Injectable } from "@angular/core";
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsService, BiometricsCommands, BiometricsStatus } from "@bitwarden/key-management";
import { BrowserBiometricsService } from "./browser-biometrics.service";
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
import { BrowserApi } from "../../platform/browser/browser-api";
@Injectable()
export class BackgroundBrowserBiometricsService extends BrowserBiometricsService {
constructor(private nativeMessagingBackground: () => NativeMessagingBackground) {
export class BackgroundBrowserBiometricsService extends BiometricsService {
constructor(
private nativeMessagingBackground: () => NativeMessagingBackground,
private logService: LogService,
) {
super();
}
async authenticateBiometric(): Promise<boolean> {
const responsePromise = this.nativeMessagingBackground().getResponse();
await this.nativeMessagingBackground().send({ command: "biometricUnlock" });
const response = await responsePromise;
return response.response === "unlocked";
async authenticateWithBiometrics(): Promise<boolean> {
try {
await this.ensureConnected();
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.Unlock,
});
return response.response == "unlocked";
} else {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.AuthenticateWithBiometrics,
});
return response.response;
}
} catch (e) {
this.logService.info("Biometric authentication failed", e);
return false;
}
}
async isBiometricUnlockAvailable(): Promise<boolean> {
const responsePromise = this.nativeMessagingBackground().getResponse();
await this.nativeMessagingBackground().send({ command: "biometricUnlockAvailable" });
const response = await responsePromise;
return response.response === "available";
async getBiometricsStatus(): Promise<BiometricsStatus> {
if (!(await BrowserApi.permissionsGranted(["nativeMessaging"]))) {
return BiometricsStatus.NativeMessagingPermissionMissing;
}
try {
await this.ensureConnected();
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.IsAvailable,
});
const resp =
response.response == "available"
? BiometricsStatus.Available
: BiometricsStatus.HardwareUnavailable;
return resp;
} else {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.GetBiometricsStatus,
});
if (response.response) {
return response.response;
}
}
return BiometricsStatus.Available;
} catch (e) {
return BiometricsStatus.DesktopDisconnected;
}
}
async biometricsNeedsSetup(): Promise<boolean> {
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
try {
await this.ensureConnected();
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.Unlock,
});
if (response.response == "unlocked") {
return response.userKeyB64;
} else {
return null;
}
} else {
const response = await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.UnlockWithBiometricsForUser,
userId: userId,
});
if (response.response) {
return response.userKeyB64;
} else {
return null;
}
}
} catch (e) {
this.logService.info("Biometric unlock for user failed", e);
throw new Error("Biometric unlock failed");
}
}
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
try {
await this.ensureConnected();
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
return await this.getBiometricsStatus();
}
return (
await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.GetBiometricsStatusForUser,
userId: id,
})
).response;
} catch (e) {
return BiometricsStatus.DesktopDisconnected;
}
}
// the first time we call, this might use an outdated version of the protocol, so we drop the response
private async ensureConnected() {
if (!this.nativeMessagingBackground().connected) {
await this.nativeMessagingBackground().callCommand({
command: BiometricsCommands.IsAvailable,
});
}
}
async getShouldAutopromptNow(): Promise<boolean> {
return false;
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return false;
}
async biometricsSetup(): Promise<void> {}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
}

View File

@@ -1,19 +0,0 @@
import { Injectable } from "@angular/core";
import { BiometricsService } from "@bitwarden/key-management";
import { BrowserApi } from "../../platform/browser/browser-api";
@Injectable()
export abstract class BrowserBiometricsService extends BiometricsService {
async supportsBiometric() {
const platformInfo = await BrowserApi.getPlatformInfo();
if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") {
return true;
}
return false;
}
abstract authenticateBiometric(): Promise<boolean>;
abstract isBiometricUnlockAvailable(): Promise<boolean>;
}

View File

@@ -1,34 +1,55 @@
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsCommands, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
import { BrowserApi } from "../../platform/browser/browser-api";
import { BrowserBiometricsService } from "./browser-biometrics.service";
export class ForegroundBrowserBiometricsService extends BiometricsService {
shouldAutopromptNow = true;
export class ForegroundBrowserBiometricsService extends BrowserBiometricsService {
async authenticateBiometric(): Promise<boolean> {
async authenticateWithBiometrics(): Promise<boolean> {
const response = await BrowserApi.sendMessageWithResponse<{
result: boolean;
error: string;
}>("biometricUnlock");
}>(BiometricsCommands.AuthenticateWithBiometrics);
if (!response.result) {
throw response.error;
}
return response.result;
}
async isBiometricUnlockAvailable(): Promise<boolean> {
async getBiometricsStatus(): Promise<BiometricsStatus> {
const response = await BrowserApi.sendMessageWithResponse<{
result: boolean;
result: BiometricsStatus;
error: string;
}>("biometricUnlockAvailable");
return response.result && response.result === true;
}>(BiometricsCommands.GetBiometricsStatus);
return response.result;
}
async biometricsNeedsSetup(): Promise<boolean> {
return false;
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
const response = await BrowserApi.sendMessageWithResponse<{
result: string;
error: string;
}>(BiometricsCommands.UnlockWithBiometricsForUser, { userId });
if (!response.result) {
return null;
}
return SymmetricCryptoKey.fromString(response.result) as UserKey;
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return false;
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
const response = await BrowserApi.sendMessageWithResponse<{
result: BiometricsStatus;
error: string;
}>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id });
return response.result;
}
async biometricsSetup(): Promise<void> {}
async getShouldAutopromptNow(): Promise<boolean> {
return this.shouldAutopromptNow;
}
async setShouldAutopromptNow(value: boolean): Promise<void> {
this.shouldAutopromptNow = value;
}
}

View File

@@ -1,91 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import {
KdfConfigService,
DefaultKeyService,
BiometricsService,
BiometricStateService,
} from "@bitwarden/key-management";
export class BrowserKeyService extends DefaultKeyService {
constructor(
pinService: PinServiceAbstraction,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
keyGenerationService: KeyGenerationService,
cryptoFunctionService: CryptoFunctionService,
encryptService: EncryptService,
platformUtilService: PlatformUtilsService,
logService: LogService,
stateService: StateService,
accountService: AccountService,
stateProvider: StateProvider,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
kdfConfigService: KdfConfigService,
) {
super(
pinService,
masterPasswordService,
keyGenerationService,
cryptoFunctionService,
encryptService,
platformUtilService,
logService,
stateService,
accountService,
stateProvider,
kdfConfigService,
);
}
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
if (keySuffix === KeySuffixOptions.Biometric) {
const biometricUnlockPromise =
userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
: this.biometricStateService.getBiometricUnlockEnabled(userId);
return await biometricUnlockPromise;
}
return super.hasUserKeyStored(keySuffix, userId);
}
/**
* Browser doesn't store biometric keys, so we retrieve them from the desktop and return
* if we successfully saved it into memory as the User Key
* @returns the `UserKey` if the user passes a biometrics prompt, otherwise return `null`.
*/
protected override async getKeyFromStorage(
keySuffix: KeySuffixOptions,
userId?: UserId,
): Promise<UserKey> {
if (keySuffix === KeySuffixOptions.Biometric) {
const biometricsResult = await this.biometricsService.authenticateBiometric();
if (!biometricsResult) {
return null;
}
const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
if (userKey) {
return userKey;
}
}
return await super.getKeyFromStorage(keySuffix, userId);
}
}

View File

@@ -9,8 +9,8 @@ import {
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, BiometricsService } from "@bitwarden/key-management";
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
import { UnlockOptions } from "@bitwarden/key-management/angular";
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
@@ -121,8 +121,7 @@ describe("ExtensionLockComponentService", () => {
describe("getAvailableUnlockOptions$", () => {
interface MockInputs {
hasMasterPassword: boolean;
osSupportsBiometric: boolean;
biometricLockSet: boolean;
biometricsStatusForUser: BiometricsStatus;
hasBiometricEncryptedUserKeyStored: boolean;
platformSupportsSecureStorage: boolean;
pinDecryptionAvailable: boolean;
@@ -133,8 +132,7 @@ describe("ExtensionLockComponentService", () => {
// MP + PIN + Biometrics available
{
hasMasterPassword: true,
osSupportsBiometric: true,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.Available,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
@@ -148,7 +146,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
@@ -156,8 +154,7 @@ describe("ExtensionLockComponentService", () => {
// PIN + Biometrics available
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.Available,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: true,
@@ -171,7 +168,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
@@ -179,8 +176,7 @@ describe("ExtensionLockComponentService", () => {
// Biometrics available: user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.Available,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
@@ -194,7 +190,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
@@ -202,8 +198,7 @@ describe("ExtensionLockComponentService", () => {
// Biometrics available: no user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.Available,
hasBiometricEncryptedUserKeyStored: false,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
@@ -217,7 +212,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
@@ -225,8 +220,7 @@ describe("ExtensionLockComponentService", () => {
// Biometrics not available: biometric lock not set
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: false,
biometricsStatusForUser: BiometricsStatus.UnlockNeeded,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
@@ -240,7 +234,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
biometricsStatus: BiometricsStatus.UnlockNeeded,
},
},
],
@@ -248,8 +242,7 @@ describe("ExtensionLockComponentService", () => {
// Biometrics not available: user key not stored
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.NotEnabledInConnectedDesktopApp,
hasBiometricEncryptedUserKeyStored: false,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
@@ -263,7 +256,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp,
},
},
],
@@ -271,8 +264,7 @@ describe("ExtensionLockComponentService", () => {
// Biometrics not available: OS doesn't support
{
hasMasterPassword: false,
osSupportsBiometric: false,
biometricLockSet: true,
biometricsStatusForUser: BiometricsStatus.HardwareUnavailable,
hasBiometricEncryptedUserKeyStored: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
@@ -286,7 +278,7 @@ describe("ExtensionLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
biometricsStatus: BiometricsStatus.HardwareUnavailable,
},
},
],
@@ -304,8 +296,12 @@ describe("ExtensionLockComponentService", () => {
);
// Biometrics
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
mockInputs.biometricsStatusForUser,
);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(
mockInputs.hasBiometricEncryptedUserKeyStored,
);
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
platformUtilsService.supportsSecureStorage.mockReturnValue(
mockInputs.platformSupportsSecureStorage,

View File

@@ -7,27 +7,17 @@ import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, BiometricsService } from "@bitwarden/key-management";
import {
LockComponentService,
BiometricsDisableReason,
UnlockOptions,
} from "@bitwarden/key-management/angular";
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
export class ExtensionLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly platformUtilsService = inject(PlatformUtilsService);
private readonly biometricsService = inject(BiometricsService);
private readonly pinService = inject(PinServiceAbstraction);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly keyService = inject(KeyService);
private readonly routerService = inject(BrowserRouterService);
getPreviousUrl(): string | null {
@@ -52,67 +42,28 @@ export class ExtensionLockComponentService implements LockComponentService {
return "unlockWithBiometrics";
}
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
KeySuffixOptions.Biometric,
userId,
);
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
return (
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
);
}
private getBiometricsDisabledReason(
osSupportsBiometric: boolean,
biometricLockSet: boolean,
): BiometricsDisableReason | null {
if (!osSupportsBiometric) {
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
} else if (!biometricLockSet) {
return BiometricsDisableReason.EncryptedKeysUnavailable;
}
return null;
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
return combineLatest([
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
defer(() => this.biometricsService.supportsBiometric()),
defer(() => this.isBiometricLockSet(userId)),
defer(async () => await this.biometricsService.getBiometricsStatusForUser(userId)),
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
]).pipe(
map(
([
supportsBiometric,
isBiometricsLockSet,
userDecryptionOptions,
pinDecryptionAvailable,
]) => {
const disableReason = this.getBiometricsDisabledReason(
supportsBiometric,
isBiometricsLockSet,
);
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled: supportsBiometric && isBiometricsLockSet,
disableReason: disableReason,
},
};
return unlockOpts;
},
),
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled: biometricsStatus === BiometricsStatus.Available,
biometricsStatus: biometricsStatus,
},
};
return unlockOpts;
}),
);
}
}

View File

@@ -111,8 +111,8 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
import {
KdfConfigService,
KeyService,
BiometricStateService,
BiometricsService,
DefaultKeyService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management/angular";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -126,7 +126,6 @@ import { AutofillService as AutofillServiceAbstraction } from "../../autofill/se
import AutofillService from "../../autofill/services/autofill.service";
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics";
import { BrowserKeyService } from "../../key-management/browser-key.service";
import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator";
@@ -232,11 +231,9 @@ const safeProviders: SafeProvider[] = [
stateService: StateService,
accountService: AccountServiceAbstraction,
stateProvider: StateProvider,
biometricStateService: BiometricStateService,
biometricsService: BiometricsService,
kdfConfigService: KdfConfigService,
) => {
const keyService = new BrowserKeyService(
const keyService = new DefaultKeyService(
pinService,
masterPasswordService,
keyGenerationService,
@@ -247,8 +244,6 @@ const safeProviders: SafeProvider[] = [
stateService,
accountService,
stateProvider,
biometricStateService,
biometricsService,
kdfConfigService,
);
new ContainerService(keyService, encryptService).attachToGlobal(self);
@@ -265,8 +260,6 @@ const safeProviders: SafeProvider[] = [
StateService,
AccountServiceAbstraction,
StateProvider,
BiometricStateService,
BiometricsService,
KdfConfigService,
],
}),

View File

@@ -86,8 +86,203 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
context.completeRequest(returningItems: [response], completionHandler: nil)
}
return
case "biometricUnlock":
case "authenticateWithBiometrics":
let messageId = message?["messageId"] as? Int
let laContext = LAContext()
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "authenticateWithBiometrics",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
break
}
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "authenticate") { (success, error) in
if success {
response.userInfo = [ SFExtensionMessageKey: [
"message": [
"command": "authenticateWithBiometrics",
"response": true,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
]]
} else {
response.userInfo = [ SFExtensionMessageKey: [
"message": [
"command": "authenticateWithBiometrics",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
]]
}
context.completeRequest(returningItems: [response], completionHandler: nil)
}
return
case "getBiometricsStatus":
let messageId = message?["messageId"] as? Int
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "getBiometricsStatus",
"response": BiometricsStatus.Available.rawValue,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
context.completeRequest(returningItems: [response], completionHandler: nil);
break
case "unlockWithBiometricsForUser":
let messageId = message?["messageId"] as? Int
var error: NSError?
let laContext = LAContext()
laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
if let e = error, e.code != kLAErrorBiometryLockout {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
context.completeRequest(returningItems: [response], completionHandler: nil)
break
}
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
let messageId = message?["messageId"] as? Int
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"response": false,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
context.completeRequest(returningItems: [response], completionHandler: nil)
break
}
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "unlock your vault") { (success, error) in
if success {
guard let userId = message?["userId"] as? String else {
return
}
let passwordName = userId + "_user_biometric"
var passwordLength: UInt32 = 0
var passwordPtr: UnsafeMutableRawPointer? = nil
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
if status != errSecSuccess {
let fallbackName = "key"
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
}
if status == errSecSuccess {
let result = NSString(bytes: passwordPtr!, length: Int(passwordLength), encoding: String.Encoding.utf8.rawValue) as String?
SecKeychainItemFreeContent(nil, passwordPtr)
response.userInfo = [ SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"response": true,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"userKeyB64": result!.replacingOccurrences(of: "\"", with: ""),
"messageId": messageId,
],
]]
} else {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "biometricUnlock",
"response": true,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
}
}
context.completeRequest(returningItems: [response], completionHandler: nil)
}
return
case "getBiometricsStatusForUser":
let messageId = message?["messageId"] as? Int
let laContext = LAContext()
if !laContext.isBiometricsAvailable() {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "getBiometricsStatusForUser",
"response": BiometricsStatus.HardwareUnavailable.rawValue,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
context.completeRequest(returningItems: [response], completionHandler: nil)
break
}
guard let userId = message?["userId"] as? String else {
return
}
let passwordName = userId + "_user_biometric"
var passwordLength: UInt32 = 0
var passwordPtr: UnsafeMutableRawPointer? = nil
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
if status != errSecSuccess {
let fallbackName = "key"
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
}
if status == errSecSuccess {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "getBiometricsStatusForUser",
"response": BiometricsStatus.Available.rawValue,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
} else {
response.userInfo = [
SFExtensionMessageKey: [
"message": [
"command": "getBiometricsStatusForUser",
"response": BiometricsStatus.NotEnabledInConnectedDesktopApp.rawValue,
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
"messageId": messageId,
],
],
]
}
break
case "biometricUnlock":
var error: NSError?
let laContext = LAContext()
if(!laContext.isBiometricsAvailable()){
@@ -115,7 +310,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
]
break
}
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Bitwarden Safari Extension") { (success, error) in
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Biometric Unlock") { (success, error) in
if success {
guard let userId = message?["userId"] as? String else {
return
@@ -157,7 +352,6 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
context.completeRequest(returningItems: [response], completionHandler: nil)
}
return
case "biometricUnlockAvailable":
let laContext = LAContext()
@@ -228,3 +422,15 @@ class DownloadFileMessage: Decodable, Encodable {
class DownloadFileMessageBlobOptions: Decodable, Encodable {
var type: String?
}
enum BiometricsStatus : Int {
case Available = 0
case UnlockNeeded = 1
case HardwareUnavailable = 2
case AutoSetupNeeded = 3
case ManualSetupNeeded = 4
case PlatformUnsupported = 5
case DesktopDisconnected = 6
case NotEnabledLocally = 7
case NotEnabledInConnectedDesktopApp = 8
}

View File

@@ -0,0 +1,27 @@
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
export class CliBiometricsService extends BiometricsService {
async authenticateWithBiometrics(): Promise<boolean> {
return false;
}
async getBiometricsStatus(): Promise<BiometricsStatus> {
return BiometricsStatus.PlatformUnsupported;
}
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
return null;
}
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return BiometricsStatus.PlatformUnsupported;
}
async getShouldAutopromptNow(): Promise<boolean> {
return false;
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
}

View File

@@ -165,6 +165,7 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
@@ -693,12 +694,12 @@ export class ServiceContainer {
this.userVerificationApiService,
this.userDecryptionOptionsService,
this.pinService,
this.logService,
this.vaultTimeoutSettingsService,
this.platformUtilsService,
this.kdfConfigService,
new CliBiometricsService(),
);
const biometricService = new CliBiometricsService();
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
@@ -714,6 +715,7 @@ export class ServiceContainer {
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
biometricService,
lockedCallback,
undefined,
);

View File

@@ -22,7 +22,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions, ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeType } from "@bitwarden/common/platform/enums/theme-type.enum";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -32,10 +32,11 @@ import {
VaultTimeoutStringType,
} from "@bitwarden/common/types/vault-timeout.type";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
@@ -54,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
themeOptions: any[];
clearClipboardOptions: any[];
supportsBiometric: boolean;
private timerId: any;
showAlwaysShowDock = false;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
@@ -139,7 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private userVerificationService: UserVerificationServiceAbstraction,
private desktopSettingsService: DesktopSettingsService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private biometricsService: DesktopBiometricsService,
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
private pinService: PinServiceAbstraction,
private logService: LogService,
@@ -297,7 +299,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Non-form values
this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop;
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.supportsBiometric = await this.biometricsService.supportsBiometric();
this.previousVaultTimeout = this.form.value.vaultTimeout;
this.refreshTimeoutSettings$
@@ -360,6 +361,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.enableBrowserIntegrationFingerprint.disable();
}
});
this.supportsBiometric =
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
this.timerId = setInterval(async () => {
this.supportsBiometric =
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
}, 1000);
}
async saveVaultTimeout(newValue: VaultTimeout) {
@@ -476,23 +484,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
const needsSetup = await this.biometricsService.biometricsNeedsSetup();
const supportsBiometricAutoSetup = await this.biometricsService.biometricsSupportsAutoSetup();
const status = await this.biometricsService.getBiometricsStatus();
if (needsSetup) {
if (supportsBiometricAutoSetup) {
await this.biometricsService.biometricsSetup();
} else {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
if (status === BiometricsStatus.AutoSetupNeeded) {
await this.biometricsService.setupBiometrics();
} else if (status === BiometricsStatus.ManualSetupNeeded) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
}
await this.biometricStateService.setBiometricUnlockEnabled(true);
@@ -513,8 +518,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
await this.keyService.refreshAdditionalKeys();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
// Validate the key is stored in case biometrics fail.
const biometricSet = await this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric);
const biometricSet =
(await this.biometricsService.getBiometricsStatusForUser(activeUserId)) ===
BiometricsStatus.Available;
this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
if (!biometricSet) {
await this.biometricStateService.setBiometricUnlockEnabled(false);
@@ -779,6 +789,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
clearInterval(this.timerId);
}
get biometricText() {

View File

@@ -17,6 +17,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
type ActiveAccount = {
id: string;
name: string;
@@ -90,6 +92,7 @@ export class AccountSwitcherComponent implements OnInit {
private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction,
private accountService: AccountService,
private biometricsService: DesktopBiometricsService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
@@ -181,6 +184,7 @@ export class AccountSwitcherComponent implements OnInit {
async switch(userId: string) {
this.close();
await this.biometricsService.setShouldAutopromptNow(true);
this.disabled = true;
const accountSwitchFinishedPromise = firstValueFrom(

View File

@@ -102,7 +102,8 @@ import { DesktopLoginComponentService } from "../../auth/login/desktop-login-com
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
import { flagEnabled } from "../../platform/flags";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@@ -142,7 +143,12 @@ const safeProviders: SafeProvider[] = [
safeProvider(InitService),
safeProvider({
provide: BiometricsService,
useClass: ElectronBiometricsService,
useClass: RendererBiometricsService,
deps: [],
}),
safeProvider({
provide: DesktopBiometricsService,
useClass: RendererBiometricsService,
deps: [],
}),
safeProvider(NativeMessagingService),
@@ -241,6 +247,7 @@ const safeProviders: SafeProvider[] = [
VaultTimeoutSettingsService,
BiometricStateService,
AccountServiceAbstraction,
LogService,
],
}),
safeProvider({
@@ -302,6 +309,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
BiometricStateService,
KdfConfigService,
DesktopBiometricsService,
],
}),
safeProvider({

View File

@@ -1,44 +0,0 @@
import { OsBiometricService } from "./desktop.biometrics.service";
export default class NoopBiometricsService implements OsBiometricService {
constructor() {}
async init() {}
async osSupportsBiometric(): Promise<boolean> {
return false;
}
async osBiometricsNeedsSetup(): Promise<boolean> {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyHalfB64: string,
): Promise<string | null> {
return null;
}
async setBiometricKey(
service: string,
storageKey: string,
value: string,
clientKeyPartB64: string | undefined,
): Promise<void> {
return;
}
async deleteBiometricKey(service: string, key: string): Promise<void> {}
async authenticateBiometric(): Promise<boolean> {
throw new Error("Not supported on this platform");
}
}

View File

@@ -1,65 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ipcMain } from "electron";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
export class BiometricsRendererIPCListener {
constructor(
private serviceName: string,
private biometricService: DesktopBiometricsService,
private logService: ConsoleLogService,
) {}
init() {
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 BiometricAction.EnabledForUser:
if (!message.key || !message.userId) {
break;
}
val = await this.biometricService.canAuthBiometric({
service: serviceName,
key: message.key,
userId: message.userId,
});
break;
case BiometricAction.OsSupported:
val = await this.biometricService.supportsBiometric();
break;
case BiometricAction.NeedsSetup:
val = await this.biometricService.biometricsNeedsSetup();
break;
case BiometricAction.Setup:
await this.biometricService.biometricsSetup();
break;
case BiometricAction.CanAutoSetup:
val = await this.biometricService.biometricsSupportsAutoSetup();
break;
default:
}
return val;
} catch (e) {
this.logService.info(e);
}
});
}
}

View File

@@ -4,14 +4,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { BiometricStateService } from "@bitwarden/key-management";
import {
BiometricsService,
BiometricsStatus,
BiometricStateService,
} from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import BiometricDarwinMain from "./biometric.darwin.main";
import BiometricWindowsMain from "./biometric.windows.main";
import { BiometricsService } from "./biometrics.service";
import { OsBiometricService } from "./desktop.biometrics.service";
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 {
@@ -28,8 +33,7 @@ describe("biometrics tests", function () {
const biometricStateService = mock<BiometricStateService>();
it("Should call the platformspecific methods", async () => {
const userId = "userId-1" as UserId;
const sut = new BiometricsService(
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
@@ -39,21 +43,15 @@ describe("biometrics tests", function () {
);
const mockService = mock<OsBiometricService>();
(sut as any).platformSpecificService = mockService;
await sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
(sut as any).osBiometricsService = mockService;
await sut.canAuthBiometric({ service: "test", key: "test", userId });
expect(mockService.osSupportsBiometric).toBeCalled();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sut.authenticateBiometric();
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 BiometricsService(
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
@@ -62,13 +60,13 @@ describe("biometrics tests", function () {
biometricStateService,
);
const internalService = (sut as any).platformSpecificService;
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(BiometricWindowsMain);
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
});
it("Should create a biometrics service specific for MacOs", () => {
const sut = new BiometricsService(
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
@@ -76,19 +74,33 @@ describe("biometrics tests", function () {
"darwin",
biometricStateService,
);
const internalService = (sut as any).platformSpecificService;
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
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>;
const userId = "userId-1" as UserId;
beforeEach(() => {
sut = new BiometricsService(
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
@@ -98,34 +110,78 @@ describe("biometrics tests", function () {
);
innerService = mock();
(sut as any).platformSpecificService = innerService;
(sut as any).osBiometricsService = innerService;
});
it("should return false if client key half is required and not provided", async () => {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
it("should return the correct biometric status for system status", async () => {
const testCases = [
// happy path
[true, false, false, BiometricsStatus.Available],
[false, true, true, BiometricsStatus.AutoSetupNeeded],
[false, true, false, BiometricsStatus.ManualSetupNeeded],
[false, false, false, BiometricsStatus.HardwareUnavailable],
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
// should not happen
[false, false, true, BiometricsStatus.HardwareUnavailable],
[true, true, true, BiometricsStatus.Available],
[true, true, false, BiometricsStatus.Available],
[true, false, true, BiometricsStatus.Available],
];
expect(result).toBe(false);
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 call osSupportsBiometric if client key half is provided", async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
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],
await sut.canAuthBiometric({ service: "test", key: "test", userId });
expect(innerService.osSupportsBiometric).toBeCalled();
});
[
BiometricsStatus.PlatformUnsupported,
true,
true,
true,
BiometricsStatus.PlatformUnsupported,
],
[BiometricsStatus.ManualSetupNeeded, true, true, true, BiometricsStatus.ManualSetupNeeded],
[BiometricsStatus.AutoSetupNeeded, true, true, true, BiometricsStatus.AutoSetupNeeded],
it("should call osSupportBiometric if client key half is not required", async () => {
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(false);
innerService.osSupportsBiometric.mockResolvedValue(true);
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
[BiometricsStatus.Available, true, true, false, BiometricsStatus.UnlockNeeded],
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
];
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
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");
}
expect(result).toBe(true);
expect(innerService.osSupportsBiometric).toHaveBeenCalled();
const actual = await sut.getBiometricsStatusForUser(userId);
expect(actual).toBe(expected);
}
});
});
});

View File

@@ -1,212 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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 { BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService, OsBiometricService } from "./desktop.biometrics.service";
export class BiometricsService extends DesktopBiometricsService {
private platformSpecificService: OsBiometricService;
private clientKeyHalves = new Map<string, string>();
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
private messagingService: MessagingService,
private platform: NodeJS.Platform,
private biometricStateService: BiometricStateService,
) {
super();
this.loadPlatformSpecificService(this.platform);
}
private loadPlatformSpecificService(platform: NodeJS.Platform) {
if (platform === "win32") {
this.loadWindowsHelloService();
} else if (platform === "darwin") {
this.loadMacOSService();
} else if (platform === "linux") {
this.loadUnixService();
} else {
this.loadNoopBiometricsService();
}
}
private loadWindowsHelloService() {
// eslint-disable-next-line
const BiometricWindowsMain = require("./biometric.windows.main").default;
this.platformSpecificService = new BiometricWindowsMain(
this.i18nService,
this.windowMain,
this.logService,
);
}
private loadMacOSService() {
// eslint-disable-next-line
const BiometricDarwinMain = require("./biometric.darwin.main").default;
this.platformSpecificService = new BiometricDarwinMain(this.i18nService);
}
private loadUnixService() {
// eslint-disable-next-line
const BiometricUnixMain = require("./biometric.unix.main").default;
this.platformSpecificService = new BiometricUnixMain(this.i18nService, this.windowMain);
}
private loadNoopBiometricsService() {
// eslint-disable-next-line
const NoopBiometricsService = require("./biometric.noop.main").default;
this.platformSpecificService = new NoopBiometricsService();
}
async supportsBiometric() {
return await this.platformSpecificService.osSupportsBiometric();
}
async biometricsNeedsSetup() {
return await this.platformSpecificService.osBiometricsNeedsSetup();
}
async biometricsSupportsAutoSetup() {
return await this.platformSpecificService.osBiometricsCanAutoSetup();
}
async biometricsSetup() {
await this.platformSpecificService.osBiometricsSetup();
}
async canAuthBiometric({
service,
key,
userId,
}: {
service: string;
key: string;
userId: UserId;
}): Promise<boolean> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
return clientKeyHalfSatisfied && (await this.supportsBiometric());
}
async authenticateBiometric(): Promise<boolean> {
let result = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.interruptProcessReload(
() => {
return this.platformSpecificService.authenticateBiometric();
},
(response) => {
result = response;
return !response;
},
);
return result;
}
async isBiometricUnlockAvailable(): Promise<boolean> {
return await this.platformSpecificService.osSupportsBiometric();
}
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");
let restartReload = false;
let response: T;
try {
response = await callback();
restartReload ||= restartReloadCallback(response);
} catch (error) {
if (error.message === "Biometric authentication failed") {
restartReload = false;
} else {
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> {
// The first half of the storageKey is the userId, separated by `_`
// We need to extract from the service because the active user isn't properly synced to the main process,
// So we can't use the observables on `biometricStateService`
const [userId] = storageKey.split("_");
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(
userId as UserId,
);
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,3 +1,4 @@
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsService } from "@bitwarden/key-management";
/**
@@ -5,58 +6,10 @@ import { BiometricsService } from "@bitwarden/key-management";
* specifically for the main process.
*/
export abstract class DesktopBiometricsService extends BiometricsService {
abstract canAuthBiometric({
service,
key,
userId,
}: {
service: string;
key: string;
userId: string;
}): Promise<boolean>;
abstract getBiometricKey(service: string, key: string): Promise<string | null>;
abstract setBiometricKey(service: string, key: string, value: string): Promise<void>;
abstract setEncryptionKeyHalf({
service,
key,
value,
}: {
service: string;
key: string;
value: string;
}): void;
abstract deleteBiometricKey(service: string, key: string): Promise<void>;
}
abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void>;
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
export interface OsBiometricService {
osSupportsBiometric(): Promise<boolean>;
/**
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
osBiometricsNeedsSetup: () => Promise<boolean>;
/**
* Check whether biometrics can be automatically setup, or requires user interaction.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
osBiometricsCanAutoSetup: () => Promise<boolean>;
/**
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
*/
osBiometricsSetup: () => Promise<void>;
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>;
abstract setupBiometrics(): Promise<void>;
abstract setClientKeyHalfForUser(userId: UserId, value: string): Promise<void>;
}

View File

@@ -1,38 +0,0 @@
import { Injectable } from "@angular/core";
import { BiometricsService } from "@bitwarden/key-management";
/**
* This service implement the base biometrics service to provide desktop specific functions,
* specifically for the renderer process by passing messages to the main process.
*/
@Injectable()
export class ElectronBiometricsService extends BiometricsService {
async supportsBiometric(): Promise<boolean> {
return await ipc.keyManagement.biometric.osSupported();
}
async isBiometricUnlockAvailable(): Promise<boolean> {
return await ipc.keyManagement.biometric.osSupported();
}
/** This method is used to authenticate the user presence _only_.
* It should not be used in the process to retrieve
* biometric keys, which has a separate authentication mechanism.
* For biometric keys, invoke "keytar" with a biometric key suffix */
async authenticateBiometric(): Promise<boolean> {
return await ipc.keyManagement.biometric.authenticate();
}
async biometricsNeedsSetup(): Promise<boolean> {
return await ipc.keyManagement.biometric.biometricsNeedsSetup();
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return await ipc.keyManagement.biometric.biometricsCanAutoSetup();
}
async biometricsSetup(): Promise<void> {
return await ipc.keyManagement.biometric.biometricsSetup();
}
}

View File

@@ -1,2 +0,0 @@
export * from "./desktop.biometrics.service";
export * from "./biometrics.service";

View File

@@ -0,0 +1,63 @@
import { ipcMain } from "electron";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
export class MainBiometricsIPCListener {
constructor(
private biometricService: DesktopBiometricsService,
private logService: ConsoleLogService,
) {}
init() {
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
try {
if (!message.action) {
return;
}
switch (message.action) {
case BiometricAction.Authenticate:
return await this.biometricService.authenticateWithBiometrics();
case BiometricAction.GetStatus:
return await this.biometricService.getBiometricsStatus();
case BiometricAction.UnlockForUser:
return await this.biometricService.unlockWithBiometricsForUser(
message.userId as UserId,
);
case BiometricAction.GetStatusForUser:
return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId);
case BiometricAction.SetKeyForUser:
return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
message.userId as UserId,
message.key,
);
case BiometricAction.RemoveKeyForUser:
return await this.biometricService.deleteBiometricUnlockKeyForUser(
message.userId as UserId,
);
case BiometricAction.SetClientKeyHalf:
return await this.biometricService.setClientKeyHalfForUser(
message.userId as UserId,
message.key,
);
case BiometricAction.Setup:
return await this.biometricService.setupBiometrics();
case BiometricAction.SetShouldAutoprompt:
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
case BiometricAction.GetShouldAutoprompt:
return await this.biometricService.getShouldAutopromptNow();
default:
return;
}
} catch (e) {
this.logService.info(e);
}
});
}
}

View File

@@ -0,0 +1,167 @@
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private clientKeyHalves = new Map<string, string>();
private shouldAutoPrompt = true;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
private messagingService: MessagingService,
private platform: NodeJS.Platform,
private biometricStateService: BiometricStateService,
) {
super();
this.loadOsBiometricService(this.platform);
}
private loadOsBiometricService(platform: NodeJS.Platform) {
if (platform === "win32") {
// eslint-disable-next-line
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
this.osBiometricsService = new OsBiometricsServiceWindows(
this.i18nService,
this.windowMain,
this.logService,
);
} else if (platform === "darwin") {
// eslint-disable-next-line
const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default;
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService);
} else if (platform === "linux") {
// eslint-disable-next-line
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
this.osBiometricsService = new OsBiometricsServiceLinux(this.i18nService, this.windowMain);
} else {
throw new Error("Unsupported platform");
}
}
/**
* Get the status of biometrics for the platform. Biometrics status for the platform can be one of:
* - Available: Biometrics are available and can be used (On windows hello, (touch id (for now)) and polkit, this MAY fall back to password)
* - HardwareUnavailable: Biometrics are not available on the platform
* - ManualSetupNeeded: In order to use biometrics, the user must perform manual steps (linux only)
* - AutoSetupNeeded: In order to use biometrics, the user must perform automatic steps (linux only)
* @returns the status of the biometrics of the platform
*/
async getBiometricsStatus(): Promise<BiometricsStatus> {
if (!(await this.osBiometricsService.osSupportsBiometric())) {
if (await this.osBiometricsService.osBiometricsNeedsSetup()) {
if (await this.osBiometricsService.osBiometricsCanAutoSetup()) {
return BiometricsStatus.AutoSetupNeeded;
} else {
return BiometricsStatus.ManualSetupNeeded;
}
}
return BiometricsStatus.HardwareUnavailable;
}
return BiometricsStatus.Available;
}
/**
* Get the status of biometric unlock for a specific user. For this, biometric unlock needs to be set up for the user in the settings.
* Next, biometrics unlock needs to be available on the platform level. If "masterpassword reprompt" is enabled, a client key half (set on first unlock) for this user
* needs to be held in memory.
* @param userId the user to check the biometric unlock status for
* @returns the status of the biometric unlock for the user
*/
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
if (!(await this.biometricStateService.getBiometricUnlockEnabled(userId))) {
return BiometricsStatus.NotEnabledLocally;
}
const platformStatus = await this.getBiometricsStatus();
if (!(platformStatus === BiometricsStatus.Available)) {
return platformStatus;
}
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
const clientKeyHalfB64 = this.clientKeyHalves.get(userId);
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
if (!clientKeyHalfSatisfied) {
return BiometricsStatus.UnlockNeeded;
}
return BiometricsStatus.Available;
}
async authenticateBiometric(): Promise<boolean> {
return await this.osBiometricsService.authenticateBiometric();
}
async setupBiometrics(): Promise<void> {
return await this.osBiometricsService.osBiometricsSetup();
}
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
this.clientKeyHalves.set(userId, value);
}
async authenticateWithBiometrics(): Promise<boolean> {
return await this.osBiometricsService.authenticateBiometric();
}
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
return SymmetricCryptoKey.fromString(
await this.osBiometricsService.getBiometricKey(
"Bitwarden_biometric",
`${userId}_user_biometric`,
this.clientKeyHalves.get(userId),
),
) as UserKey;
}
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
const service = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
if (!this.clientKeyHalves.has(userId)) {
throw new Error("No client key half provided for user");
}
return await this.osBiometricsService.setBiometricKey(
service,
storageKey,
value,
this.clientKeyHalves.get(userId),
);
}
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
return await this.osBiometricsService.deleteBiometricKey(
"Bitwarden_biometric",
`${userId}_user_biometric`,
);
}
/**
* Set whether to auto-prompt the user for biometric unlock; this can be used to prevent auto-prompting being initiated by a process reload.
* Reasons for enabling auto prompt include: Starting the app, un-minimizing the app, manually account switching
* @param value Whether to auto-prompt the user for biometric unlock
*/
async setShouldAutopromptNow(value: boolean): Promise<void> {
this.shouldAutoPrompt = value;
}
/**
* Get whether to auto-prompt the user for biometric unlock; If the user is auto-prompted, setShouldAutopromptNow should be immediately called with false in order to prevent another auto-prompt.
* @returns Whether to auto-prompt the user for biometric unlock
*/
async getShouldAutopromptNow(): Promise<boolean> {
return this.shouldAutoPrompt;
}
}

View File

@@ -9,7 +9,7 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { WindowMain } from "../../main/window.main";
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
import { OsBiometricService } from "./desktop.biometrics.service";
import { OsBiometricService } from "./os-biometrics.service";
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
@@ -30,7 +30,7 @@ const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
export default class BiometricUnixMain implements OsBiometricService {
export default class OsBiometricsServiceLinux implements OsBiometricService {
constructor(
private i18nservice: I18nService,
private windowMain: WindowMain,

View File

@@ -3,9 +3,9 @@ import { systemPreferences } from "electron";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { passwords } from "@bitwarden/desktop-napi";
import { OsBiometricService } from "./desktop.biometrics.service";
import { OsBiometricService } from "./os-biometrics.service";
export default class BiometricDarwinMain implements OsBiometricService {
export default class OsBiometricsServiceMac implements OsBiometricService {
constructor(private i18nservice: I18nService) {}
async osSupportsBiometric(): Promise<boolean> {

View File

@@ -8,12 +8,12 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { WindowMain } from "../../main/window.main";
import { OsBiometricService } from "./desktop.biometrics.service";
import { OsBiometricService } from "./os-biometrics.service";
const KEY_WITNESS_SUFFIX = "_witness";
const WITNESS_VALUE = "known key";
export default class BiometricWindowsMain implements OsBiometricService {
export default class OsBiometricsServiceWindows implements OsBiometricService {
// Use set helper method instead of direct access
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
@@ -113,13 +113,19 @@ export default class BiometricWindowsMain implements OsBiometricService {
this._iv = keyMaterial.ivB64;
}
return {
const result = {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
// napi-rs fails to convert null values
if (result.key_material.clientKeyPartB64 == null) {
delete result.key_material.clientKeyPartB64;
}
return result;
}
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
@@ -211,10 +217,17 @@ export default class BiometricWindowsMain implements OsBiometricService {
clientKeyPartB64: string,
): biometrics.KeyMaterial {
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
return {
const result = {
osKeyPartB64: key,
clientKeyPartB64,
};
// napi-rs fails to convert null values
if (result.clientKeyPartB64 == null) {
delete result.clientKeyPartB64;
}
return result;
}
async osBiometricsNeedsSetup() {

View File

@@ -0,0 +1,32 @@
export interface OsBiometricService {
osSupportsBiometric(): Promise<boolean>;
/**
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
osBiometricsNeedsSetup: () => Promise<boolean>;
/**
* Check whether biometrics can be automatically setup, or requires user interaction.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
osBiometricsCanAutoSetup: () => Promise<boolean>;
/**
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
*/
osBiometricsSetup: () => Promise<void>;
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

@@ -0,0 +1,54 @@
import { Injectable } from "@angular/core";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsStatus } from "@bitwarden/key-management";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
/**
* This service implement the base biometrics service to provide desktop specific functions,
* specifically for the renderer process by passing messages to the main process.
*/
@Injectable()
export class RendererBiometricsService extends DesktopBiometricsService {
async authenticateWithBiometrics(): Promise<boolean> {
return await ipc.keyManagement.biometric.authenticateWithBiometrics();
}
async getBiometricsStatus(): Promise<BiometricsStatus> {
return await ipc.keyManagement.biometric.getBiometricsStatus();
}
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
return await ipc.keyManagement.biometric.unlockWithBiometricsForUser(userId);
}
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id);
}
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value);
}
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
return await ipc.keyManagement.biometric.deleteBiometricUnlockKeyForUser(userId);
}
async setupBiometrics(): Promise<void> {
return await ipc.keyManagement.biometric.setupBiometrics();
}
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value);
}
async getShouldAutopromptNow(): Promise<boolean> {
return await ipc.keyManagement.biometric.getShouldAutoprompt();
}
async setShouldAutopromptNow(value: boolean): Promise<void> {
return await ipc.keyManagement.biometric.setShouldAutoprompt(value);
}
}

View File

@@ -10,8 +10,8 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { DeviceType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, BiometricsService } from "@bitwarden/key-management";
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
import { UnlockOptions } from "@bitwarden/key-management/angular";
import { DesktopLockComponentService } from "./desktop-lock-component.service";
@@ -140,11 +140,7 @@ describe("DesktopLockComponentService", () => {
describe("getAvailableUnlockOptions$", () => {
interface MockInputs {
hasMasterPassword: boolean;
osSupportsBiometric: boolean;
biometricLockSet: boolean;
biometricReady: boolean;
hasBiometricEncryptedUserKeyStored: boolean;
platformSupportsSecureStorage: boolean;
biometricsStatus: BiometricsStatus;
pinDecryptionAvailable: boolean;
}
@@ -153,11 +149,7 @@ describe("DesktopLockComponentService", () => {
// MP + PIN + Biometrics available
{
hasMasterPassword: true,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
biometricsStatus: BiometricsStatus.Available,
pinDecryptionAvailable: true,
},
{
@@ -169,7 +161,7 @@ describe("DesktopLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
@@ -177,11 +169,7 @@ describe("DesktopLockComponentService", () => {
// PIN + Biometrics available
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
biometricsStatus: BiometricsStatus.Available,
pinDecryptionAvailable: true,
},
{
@@ -193,43 +181,16 @@ describe("DesktopLockComponentService", () => {
},
biometrics: {
enabled: true,
disableReason: null,
},
},
],
[
// Biometrics available: user key stored with no secure storage
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: false,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
biometricsStatus: BiometricsStatus.Available,
},
},
],
[
// Biometrics available: no user key stored with no secure storage
// Biometric auth is available, but not unlock since there is no way to access the userkey
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
biometricReady: true,
platformSupportsSecureStorage: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
pinDecryptionAvailable: false,
},
{
@@ -240,8 +201,8 @@ describe("DesktopLockComponentService", () => {
enabled: false,
},
biometrics: {
enabled: true,
disableReason: null,
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
},
},
],
@@ -249,11 +210,7 @@ describe("DesktopLockComponentService", () => {
// Biometrics not available: biometric not ready
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: false,
platformSupportsSecureStorage: true,
biometricsStatus: BiometricsStatus.HardwareUnavailable,
pinDecryptionAvailable: false,
},
{
@@ -265,55 +222,7 @@ describe("DesktopLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.SystemBiometricsUnavailable,
},
},
],
[
// Biometrics not available: biometric lock not set
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: false,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
},
},
],
[
// Biometrics not available: user key not stored
{
hasMasterPassword: false,
osSupportsBiometric: true,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: false,
biometricReady: true,
platformSupportsSecureStorage: true,
pinDecryptionAvailable: false,
},
{
masterPassword: {
enabled: false,
},
pin: {
enabled: false,
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
biometricsStatus: BiometricsStatus.HardwareUnavailable,
},
},
],
@@ -321,11 +230,7 @@ describe("DesktopLockComponentService", () => {
// Biometrics not available: OS doesn't support
{
hasMasterPassword: false,
osSupportsBiometric: false,
biometricLockSet: true,
hasBiometricEncryptedUserKeyStored: true,
biometricReady: true,
platformSupportsSecureStorage: true,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
pinDecryptionAvailable: false,
},
{
@@ -337,7 +242,7 @@ describe("DesktopLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
},
],
@@ -355,13 +260,8 @@ describe("DesktopLockComponentService", () => {
);
// Biometrics
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
platformUtilsService.supportsSecureStorage.mockReturnValue(
mockInputs.platformSupportsSecureStorage,
);
biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady);
// TODO: FIXME
biometricsService.getBiometricsStatusForUser.mockResolvedValue(mockInputs.biometricsStatus);
// PIN
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);

View File

@@ -5,25 +5,17 @@ import {
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, BiometricsService } from "@bitwarden/key-management";
import {
BiometricsDisableReason,
LockComponentService,
UnlockOptions,
} from "@bitwarden/key-management/angular";
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
export class DesktopLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly platformUtilsService = inject(PlatformUtilsService);
private readonly biometricsService = inject(BiometricsService);
private readonly pinService = inject(PinServiceAbstraction);
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private readonly keyService = inject(KeyService);
constructor() {}
@@ -52,77 +44,29 @@ export class DesktopLockComponentService implements LockComponentService {
}
}
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
KeySuffixOptions.Biometric,
userId,
);
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
return (
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
);
}
private async isBiometricsSupportedAndReady(
userId: UserId,
): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> {
const supportsBiometric = await this.biometricsService.supportsBiometric();
const biometricReady = await ipc.keyManagement.biometric.enabled(userId);
return { supportsBiometric, biometricReady };
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
return combineLatest([
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
defer(() => this.isBiometricsSupportedAndReady(userId)),
defer(() => this.isBiometricLockSet(userId)),
defer(() => this.biometricsService.getBiometricsStatusForUser(userId)),
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
]).pipe(
map(
([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => {
const disableReason = this.getBiometricsDisabledReason(
biometricsData.supportsBiometric,
isBiometricsLockSet,
biometricsData.biometricReady,
);
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled: biometricsStatus == BiometricsStatus.Available,
biometricsStatus: biometricsStatus,
},
};
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
},
pin: {
enabled: pinDecryptionAvailable,
},
biometrics: {
enabled:
biometricsData.supportsBiometric &&
isBiometricsLockSet &&
biometricsData.biometricReady,
disableReason: disableReason,
},
};
return unlockOpts;
},
),
return unlockOpts;
}),
);
}
private getBiometricsDisabledReason(
osSupportsBiometric: boolean,
biometricLockSet: boolean,
biometricReady: boolean,
): BiometricsDisableReason | null {
if (!osSupportsBiometric) {
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
} else if (!biometricLockSet) {
return BiometricsDisableReason.EncryptedKeysUnavailable;
} else if (!biometricReady) {
return BiometricsDisableReason.SystemBiometricsUnavailable;
}
return null;
}
}

View File

@@ -1,36 +1,58 @@
import { ipcRenderer } from "electron";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsStatus } from "@bitwarden/key-management";
import { BiometricMessage, BiometricAction } from "../types/biometric-message";
const biometric = {
enabled: (userId: string): Promise<boolean> =>
authenticateWithBiometrics: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnabledForUser,
key: `${userId}_user_biometric`,
keySuffix: KeySuffixOptions.Biometric,
action: BiometricAction.Authenticate,
} satisfies BiometricMessage),
getBiometricsStatus: (): Promise<BiometricsStatus> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.GetStatus,
} satisfies BiometricMessage),
unlockWithBiometricsForUser: (userId: string): Promise<UserKey | null> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.UnlockForUser,
userId: userId,
} satisfies BiometricMessage),
osSupported: (): Promise<boolean> =>
getBiometricsStatusForUser: (userId: string): Promise<BiometricsStatus> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.OsSupported,
action: BiometricAction.GetStatusForUser,
userId: userId,
} satisfies BiometricMessage),
biometricsNeedsSetup: (): Promise<boolean> =>
setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.NeedsSetup,
action: BiometricAction.SetKeyForUser,
userId: userId,
key: value,
} satisfies BiometricMessage),
biometricsSetup: (): Promise<void> =>
deleteBiometricUnlockKeyForUser: (userId: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.RemoveKeyForUser,
userId: userId,
} satisfies BiometricMessage),
setupBiometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Setup,
} satisfies BiometricMessage),
biometricsCanAutoSetup: (): Promise<boolean> =>
setClientKeyHalf: (userId: string, value: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.CanAutoSetup,
action: BiometricAction.SetClientKeyHalf,
userId: userId,
key: value,
} satisfies BiometricMessage),
authenticate: (): Promise<boolean> =>
getShouldAutoprompt: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Authenticate,
action: BiometricAction.GetShouldAutoprompt,
} satisfies BiometricMessage),
setShouldAutoprompt: (should: boolean): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.SetShouldAutoprompt,
data: should,
} satisfies BiometricMessage),
};

View File

@@ -3362,6 +3362,30 @@
"ssoError": {
"message": "No free ports could be found for the sso login."
},
"biometricsStatusHelptextUnlockNeeded": {
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
},
"biometricsStatusHelptextHardwareUnavailable": {
"message": "Biometric unlock is currently unavailable."
},
"biometricsStatusHelptextAutoSetupNeeded": {
"message": "Biometric unlock is unavailable due to misconfigured system files."
},
"biometricsStatusHelptextManualSetupNeeded": {
"message": "Biometric unlock is unavailable due to misconfigured system files."
},
"biometricsStatusHelptextNotEnabledLocally": {
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
"placeholders": {
"email": {
"content": "$1",
"example": "mail@example.com"
}
}
},
"biometricsStatusHelptextUnavailableReasonUnknown": {
"message": "Biometric unlock is currently unavailable for an unknown reason."
},
"authorize": {
"message": "Authorize"
},

View File

@@ -28,8 +28,9 @@ import { DefaultBiometricStateService } from "@bitwarden/key-management";
/* eslint-enable import/no-restricted-paths */
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
import { BiometricsRendererIPCListener } from "./key-management/biometrics/biometric.renderer-ipc.listener";
import { BiometricsService, DesktopBiometricsService } from "./key-management/biometrics/index";
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
import { MenuMain } from "./main/menu/menu.main";
import { MessagingMain } from "./main/messaging.main";
import { NativeMessagingMain } from "./main/native-messaging.main";
@@ -61,7 +62,7 @@ export class Main {
messagingService: MessageSender;
environmentService: DefaultEnvironmentService;
desktopCredentialStorageListener: DesktopCredentialStorageListener;
biometricsRendererIPCListener: BiometricsRendererIPCListener;
mainBiometricsIpcListener: MainBiometricsIPCListener;
desktopSettingsService: DesktopSettingsService;
mainCryptoFunctionService: MainCryptoFunctionService;
migrationRunner: MigrationRunner;
@@ -177,6 +178,15 @@ export class Main {
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
const biometricStateService = new DefaultBiometricStateService(stateProvider);
this.biometricsService = new MainBiometricsService(
this.i18nService,
this.windowMain,
this.logService,
this.messagingService,
process.platform,
biometricStateService,
);
this.windowMain = new WindowMain(
biometricStateService,
this.logService,
@@ -187,7 +197,6 @@ export class Main {
);
this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService);
const messageSubject = new Subject<Message<Record<string, unknown>>>();
this.messagingService = MessageSender.combine(
@@ -218,22 +227,19 @@ export class Main {
this.versionMain,
);
this.biometricsService = new BiometricsService(
this.i18nService,
this.trayMain = new TrayMain(
this.windowMain,
this.logService,
this.messagingService,
process.platform,
this.i18nService,
this.desktopSettingsService,
biometricStateService,
this.biometricsService,
);
this.desktopCredentialStorageListener = new DesktopCredentialStorageListener(
"Bitwarden",
this.biometricsService,
this.logService,
);
this.biometricsRendererIPCListener = new BiometricsRendererIPCListener(
"Bitwarden",
this.mainBiometricsIpcListener = new MainBiometricsIPCListener(
this.biometricsService,
this.logService,
);
@@ -267,7 +273,7 @@ export class Main {
bootstrap() {
this.desktopCredentialStorageListener.init();
this.biometricsRendererIPCListener.init();
this.mainBiometricsIpcListener.init();
// Run migrations first, then other things
this.migrationRunner.run().then(
async () => {

View File

@@ -6,6 +6,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -23,6 +24,8 @@ export class TrayMain {
private windowMain: WindowMain,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private biometricsStateService: BiometricStateService,
private biometricService: BiometricsService,
) {
if (process.platform === "win32") {
this.icon = path.join(__dirname, "/images/icon.ico");
@@ -72,6 +75,10 @@ export class TrayMain {
}
});
win.on("restore", async () => {
await this.biometricService.setShouldAutopromptNow(true);
});
win.on("close", async (e: Event) => {
if (await firstValueFrom(this.desktopSettingsService.closeToTray$)) {
if (!this.windowMain.isQuitting) {

View File

@@ -1,5 +1,6 @@
export type LegacyMessage = {
command: string;
messageId: number;
userId?: string;
timestamp?: number;

View File

@@ -2,18 +2,12 @@
// @ts-strict-ignore
import { ipcMain } from "electron";
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { passwords } from "@bitwarden/desktop-napi";
import { DesktopBiometricsService } from "../../key-management/biometrics/index";
const AuthRequiredSuffix = "_biometric";
export class DesktopCredentialStorageListener {
constructor(
private serviceName: string,
private biometricService: DesktopBiometricsService,
private logService: ConsoleLogService,
) {}
@@ -54,13 +48,7 @@ export class DesktopCredentialStorageListener {
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
private async getPassword(serviceName: string, key: string, keySuffix: string) {
let val: string;
// todo: remove this when biometrics has been migrated to desktop_native
if (keySuffix === AuthRequiredSuffix) {
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
} else {
val = await passwords.getPassword(serviceName, key);
}
const val = await passwords.getPassword(serviceName, key);
try {
JSON.parse(val);
@@ -72,25 +60,10 @@ export class DesktopCredentialStorageListener {
}
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);
}
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);
}
await passwords.deletePassword(serviceName, key);
}
}

View File

@@ -87,6 +87,7 @@ const nativeMessaging = {
},
sendMessage: (message: {
appId: string;
messageId?: number;
command?: string;
sharedSecret?: string;
message?: EncString;

View File

@@ -1,115 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { mock } from "jest-mock-extended";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, BiometricStateService } from "@bitwarden/key-management";
import {
FakeAccountService,
mockAccountServiceWith,
} from "../../../../../libs/common/spec/fake-account-service";
import { ElectronKeyService } from "./electron-key.service";
describe("electronKeyService", () => {
let sut: ElectronKeyService;
const pinService = mock<PinServiceAbstraction>();
const keyGenerationService = mock<KeyGenerationService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const encryptService = mock<EncryptService>();
const platformUtilService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
const stateService = mock<StateService>();
let masterPasswordService: FakeMasterPasswordService;
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
const biometricStateService = mock<BiometricStateService>();
const kdfConfigService = mock<KdfConfigService>();
const mockUserId = "mock user id" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith("userId" as UserId);
masterPasswordService = new FakeMasterPasswordService();
stateProvider = new FakeStateProvider(accountService);
sut = new ElectronKeyService(
pinService,
masterPasswordService,
keyGenerationService,
cryptoFunctionService,
encryptService,
platformUtilService,
logService,
stateService,
accountService,
stateProvider,
biometricStateService,
kdfConfigService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("setUserKey", () => {
let mockUserKey: UserKey;
beforeEach(() => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
});
describe("Biometric Key refresh", () => {
const encClientKeyHalf = makeEncString();
const decClientKeyHalf = "decrypted client key half";
beforeEach(() => {
encClientKeyHalf.decrypt = jest.fn().mockResolvedValue(decClientKeyHalf);
});
it("sets a Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => {
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
platformUtilService.supportsSecureStorage.mockReturnValue(true);
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(encClientKeyHalf);
await sut.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(
expect.objectContaining({ key: expect.any(String), clientEncKeyHalf: decClientKeyHalf }),
{
userId: mockUserId,
},
);
});
it("clears the Biometric key if getBiometricUnlock is false or the platform does not support secure storage", async () => {
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
platformUtilService.supportsSecureStorage.mockReturnValue(false);
await sut.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(null, {
userId: mockUserId,
});
});
});
});
});

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -13,7 +11,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CsprngString } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
@@ -24,6 +21,8 @@ import {
BiometricStateService,
} from "@bitwarden/key-management";
import { DesktopBiometricsService } from "src/key-management/biometrics/desktop.biometrics.service";
export class ElectronKeyService extends DefaultKeyService {
constructor(
pinService: PinServiceAbstraction,
@@ -38,6 +37,7 @@ export class ElectronKeyService extends DefaultKeyService {
stateProvider: StateProvider,
private biometricStateService: BiometricStateService,
kdfConfigService: KdfConfigService,
private biometricService: DesktopBiometricsService,
) {
super(
pinService,
@@ -55,19 +55,10 @@ export class ElectronKeyService extends DefaultKeyService {
}
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
if (keySuffix === KeySuffixOptions.Biometric) {
return await this.stateService.hasUserKeyBiometric({ userId: userId });
}
return super.hasUserKeyStored(keySuffix, userId);
}
override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
if (keySuffix === KeySuffixOptions.Biometric) {
await this.stateService.setUserKeyBiometric(null, { userId: userId });
await this.biometricStateService.removeEncryptedClientKeyHalf(userId);
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
await super.clearStoredUserKey(keySuffix, userId);
@@ -76,52 +67,35 @@ export class ElectronKeyService extends DefaultKeyService {
protected override async storeAdditionalKeys(key: UserKey, userId: UserId) {
await super.storeAdditionalKeys(key, userId);
const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId);
if (storeBiometricKey) {
await this.storeBiometricKey(key, userId);
} else {
await this.stateService.setUserKeyBiometric(null, { userId: userId });
if (await this.biometricStateService.getBiometricUnlockEnabled(userId)) {
await this.storeBiometricsProtectedUserKey(key, userId);
}
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
}
protected override async getKeyFromStorage(
keySuffix: KeySuffixOptions,
userId?: UserId,
): Promise<UserKey> {
if (keySuffix === KeySuffixOptions.Biometric) {
const userKey = await this.stateService.getUserKeyBiometric({ userId: userId });
return userKey == null
? null
: (new SymmetricCryptoKey(Utils.fromB64ToArray(userKey)) as UserKey);
}
return await super.getKeyFromStorage(keySuffix, userId);
}
protected async storeBiometricKey(key: UserKey, userId?: UserId): Promise<void> {
protected async storeBiometricsProtectedUserKey(
userKey: UserKey,
userId?: UserId,
): Promise<void> {
// May resolve to null, in which case no client key have is required
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(key, userId);
await this.stateService.setUserKeyBiometric(
{ key: key.keyB64, clientEncKeyHalf },
{ userId: userId },
);
// TODO: Move to windows implementation
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId);
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf);
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64);
}
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
if (keySuffix === KeySuffixOptions.Biometric) {
const biometricUnlockPromise =
userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
: this.biometricStateService.getBiometricUnlockEnabled(userId);
const biometricUnlock = await biometricUnlockPromise;
return biometricUnlock && this.platformUtilService.supportsSecureStorage();
}
return await super.shouldStoreKey(keySuffix, userId);
}
protected override async clearAllStoredUserKeys(userId?: UserId): Promise<void> {
await this.clearStoredUserKey(KeySuffixOptions.Biometric, userId);
await this.biometricService.deleteBiometricUnlockKeyForUser(userId);
await super.clearAllStoredUserKeys(userId);
}
@@ -135,18 +109,18 @@ export class ElectronKeyService extends DefaultKeyService {
}
// Retrieve existing key half if it exists
let biometricKey = await this.biometricStateService
let clientKeyHalf = await this.biometricStateService
.getEncryptedClientKeyHalf(userId)
.then((result) => result?.decrypt(null /* user encrypted */, userKey))
.then((result) => result as CsprngString);
if (biometricKey == null && userKey != null) {
if (clientKeyHalf == null && userKey != null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
clientKeyHalf = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
const encKey = await this.encryptService.encrypt(clientKeyHalf, userKey);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
return biometricKey;
return clientKeyHalf;
}
}

View File

@@ -0,0 +1,123 @@
import { NgZone } from "@angular/core";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { BiometricMessageHandlerService } from "./biometric-message-handler.service";
(global as any).ipc = {
platform: {
reloadProcess: jest.fn(),
},
};
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts = {
[SomeUser]: {
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
};
describe("BiometricMessageHandlerService", () => {
let service: BiometricMessageHandlerService;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let logService: MockProxy<LogService>;
let messagingService: MockProxy<MessagingService>;
let desktopSettingsService: DesktopSettingsService;
let biometricStateService: BiometricStateService;
let biometricsService: MockProxy<BiometricsService>;
let dialogService: MockProxy<DialogService>;
let accountService: AccountService;
let authService: MockProxy<AuthService>;
let ngZone: MockProxy<NgZone>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
logService = mock<LogService>();
messagingService = mock<MessagingService>();
desktopSettingsService = mock<DesktopSettingsService>();
biometricStateService = mock<BiometricStateService>();
biometricsService = mock<BiometricsService>();
dialogService = mock<DialogService>();
accountService = new FakeAccountService(accounts);
authService = mock<AuthService>();
ngZone = mock<NgZone>();
service = new BiometricMessageHandlerService(
cryptoFunctionService,
keyService,
encryptService,
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
);
});
describe("process reload", () => {
const testCases = [
// don't reload when the active user is the requested one and unlocked
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, false, false],
// do reload when the active user is the requested one but locked
[SomeUser, AuthenticationStatus.Locked, SomeUser, false, true],
// always reload when another user is active than the requested one
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, false, true],
[SomeUser, AuthenticationStatus.Locked, AnotherUser, false, true],
// don't reload in dev mode
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, true, false],
[SomeUser, AuthenticationStatus.Locked, SomeUser, true, false],
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, true, false],
[SomeUser, AuthenticationStatus.Locked, AnotherUser, true, false],
];
it.each(testCases)(
"process reload for active user %s with auth status %s and other user %s and isdev: %s should process reload: %s",
async (activeUser, authStatus, messageUser, isDev, shouldReload) => {
await accountService.switchAccount(activeUser as UserId);
authService.authStatusFor$.mockReturnValue(of(authStatus as AuthenticationStatus));
(global as any).ipc.platform.isDev = isDev;
(global as any).ipc.platform.reloadProcess.mockClear();
await service.processReloadWhenRequired(messageUser as UserId);
if (shouldReload) {
expect((global as any).ipc.platform.reloadProcess).toHaveBeenCalled();
} else {
expect((global as any).ipc.platform.reloadProcess).not.toHaveBeenCalled();
}
},
);
});
});

View File

@@ -10,13 +10,18 @@ import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/c
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import {
BiometricStateService,
BiometricsCommands,
BiometricsService,
BiometricsStatus,
KeyService,
} from "@bitwarden/key-management";
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
import { LegacyMessage } from "../models/native-messaging/legacy-message";
@@ -54,6 +59,9 @@ export class BiometricMessageHandlerService {
const accounts = await firstValueFrom(this.accountService.accounts$);
const userIds = Object.keys(accounts);
if (!userIds.includes(rawMessage.userId)) {
this.logService.info(
"[Native Messaging IPC] Received message for user that is not logged into the desktop app.",
);
ipc.platform.nativeMessaging.sendMessage({
command: "wrongUserId",
appId: appId,
@@ -62,6 +70,7 @@ export class BiometricMessageHandlerService {
}
if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) {
this.logService.info("[Native Messaging IPC] Requesting fingerprint verification.");
ipc.platform.nativeMessaging.sendMessage({
command: "verifyFingerprint",
appId: appId,
@@ -81,6 +90,7 @@ export class BiometricMessageHandlerService {
const browserSyncVerified = await firstValueFrom(dialogRef.closed);
if (browserSyncVerified !== true) {
this.logService.info("[Native Messaging IPC] Fingerprint verification failed.");
return;
}
}
@@ -90,6 +100,9 @@ export class BiometricMessageHandlerService {
}
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
this.logService.info(
"[Native Messaging IPC] Epheremal secret for secure channel is missing. Invalidating encryption...",
);
ipc.platform.nativeMessaging.sendMessage({
command: "invalidateEncryption",
appId: appId,
@@ -106,6 +119,9 @@ export class BiometricMessageHandlerService {
// Shared secret is invalidated, force re-authentication
if (message == null) {
this.logService.info(
"[Native Messaging IPC] Secure channel failed to decrypt message. Invalidating encryption...",
);
ipc.platform.nativeMessaging.sendMessage({
command: "invalidateEncryption",
appId: appId,
@@ -114,20 +130,86 @@ export class BiometricMessageHandlerService {
}
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
this.logService.error("NativeMessage is to old, ignoring.");
this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
return;
}
const messageId = message.messageId;
switch (message.command) {
case "biometricUnlock": {
case BiometricsCommands.UnlockWithBiometricsForUser: {
await this.handleUnlockWithBiometricsForUser(message, messageId, appId);
break;
}
case BiometricsCommands.AuthenticateWithBiometrics: {
try {
const unlocked = await this.biometricsService.authenticateWithBiometrics();
await this.send(
{
command: BiometricsCommands.AuthenticateWithBiometrics,
messageId,
response: unlocked,
},
appId,
);
} catch (e) {
this.logService.error("[Native Messaging IPC] Biometric authentication failed", e);
await this.send(
{ command: BiometricsCommands.AuthenticateWithBiometrics, messageId, response: false },
appId,
);
}
break;
}
case BiometricsCommands.GetBiometricsStatus: {
const status = await this.biometricsService.getBiometricsStatus();
return this.send(
{
command: BiometricsCommands.GetBiometricsStatus,
messageId,
response: status,
},
appId,
);
}
case BiometricsCommands.GetBiometricsStatusForUser: {
let status = await this.biometricsService.getBiometricsStatusForUser(
message.userId as UserId,
);
if (status == BiometricsStatus.NotEnabledLocally) {
status = BiometricsStatus.NotEnabledInConnectedDesktopApp;
}
return this.send(
{
command: BiometricsCommands.GetBiometricsStatusForUser,
messageId,
response: status,
},
appId,
);
}
// TODO: legacy, remove after 2025.01
case BiometricsCommands.IsAvailable: {
const available =
(await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available;
return this.send(
{
command: BiometricsCommands.IsAvailable,
response: available ? "available" : "not available",
},
appId,
);
}
// TODO: legacy, remove after 2025.01
case BiometricsCommands.Unlock: {
const isTemporarilyDisabled =
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
!(await this.biometricsService.supportsBiometric());
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
if (isTemporarilyDisabled) {
return this.send({ command: "biometricUnlock", response: "not available" }, appId);
}
if (!(await this.biometricsService.supportsBiometric())) {
if (!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available)) {
return this.send({ command: "biometricUnlock", response: "not supported" }, appId);
}
@@ -158,10 +240,7 @@ export class BiometricMessageHandlerService {
}
try {
const userKey = await this.keyService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
message.userId,
);
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
if (userKey != null) {
await this.send(
@@ -189,19 +268,8 @@ export class BiometricMessageHandlerService {
} catch (e) {
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
}
break;
}
case "biometricUnlockAvailable": {
const isAvailable = await this.biometricsService.supportsBiometric();
return this.send(
{
command: "biometricUnlockAvailable",
response: isAvailable ? "available" : "not available",
},
appId,
);
}
default:
this.logService.error("NativeMessage, got unknown command: " + message.command);
break;
@@ -216,7 +284,11 @@ export class BiometricMessageHandlerService {
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
);
ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted });
ipc.platform.nativeMessaging.sendMessage({
appId: appId,
messageId: message.messageId,
message: encrypted,
});
}
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
@@ -226,6 +298,7 @@ export class BiometricMessageHandlerService {
new SymmetricCryptoKey(secret).keyB64,
);
this.logService.info("[Native Messaging IPC] Setting up secure channel");
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
secret,
remotePublicKey,
@@ -234,7 +307,62 @@ export class BiometricMessageHandlerService {
ipc.platform.nativeMessaging.sendMessage({
appId: appId,
command: "setupEncryption",
messageId: -1, // to indicate to the other side that this is a new desktop client. refactor later to use proper versioning
sharedSecret: Utils.fromBufferToB64(encryptedSecret),
});
}
private async handleUnlockWithBiometricsForUser(
message: LegacyMessage,
messageId: number,
appId: string,
) {
const messageUserId = message.userId as UserId;
try {
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
if (userKey != null) {
this.logService.info("[Native Messaging IPC] Biometric unlock for user: " + messageUserId);
await this.send(
{
command: BiometricsCommands.UnlockWithBiometricsForUser,
response: true,
messageId,
userKeyB64: userKey.keyB64,
},
appId,
);
await this.processReloadWhenRequired(messageUserId);
} else {
await this.send(
{
command: BiometricsCommands.UnlockWithBiometricsForUser,
messageId,
response: false,
},
appId,
);
}
} catch (e) {
await this.send(
{ command: BiometricsCommands.UnlockWithBiometricsForUser, messageId, response: false },
appId,
);
}
}
/** A process reload after a biometric unlock should happen if the userkey that was used for biometric unlock is for a different user than the
* currently active account. The userkey for the active account was in memory anyways. Further, if the desktop app is locked, a reload should occur (since the userkey was not already in memory).
*/
async processReloadWhenRequired(messageUserId: UserId) {
const currentlyActiveAccountId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const isCurrentlyActiveAccountUnlocked =
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
AuthenticationStatus.Unlocked;
if (currentlyActiveAccountId !== messageUserId || !isCurrentlyActiveAccountUnlocked) {
if (!ipc.platform.isDev) {
ipc.platform.reloadProcess();
}
}
}
}

View File

@@ -1,15 +1,23 @@
export enum BiometricAction {
EnabledForUser = "enabled",
OsSupported = "osSupported",
Authenticate = "authenticate",
NeedsSetup = "needsSetup",
GetStatus = "status",
UnlockForUser = "unlockForUser",
GetStatusForUser = "statusForUser",
SetKeyForUser = "setKeyForUser",
RemoveKeyForUser = "removeKeyForUser",
SetClientKeyHalf = "setClientKeyHalf",
Setup = "setup",
CanAutoSetup = "canAutoSetup",
GetShouldAutoprompt = "getShouldAutoprompt",
SetShouldAutoprompt = "setShouldAutoprompt",
}
export type BiometricMessage = {
action: BiometricAction;
keySuffix?: string;
key?: string;
userId?: string;
data?: any;
};

View File

@@ -4,6 +4,7 @@ import { firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus } from "@bitwarden/key-management";
import { WebLockComponentService } from "./web-lock-component.service";
@@ -86,7 +87,7 @@ describe("WebLockComponentService", () => {
},
biometrics: {
enabled: false,
disableReason: null,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
});
});

View File

@@ -6,6 +6,7 @@ import {
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
export class WebLockComponentService implements LockComponentService {
@@ -45,7 +46,7 @@ export class WebLockComponentService implements LockComponentService {
},
biometrics: {
enabled: false,
disableReason: null,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
};
return unlockOpts;

View File

@@ -1,27 +1,27 @@
import { BiometricsService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
export class WebBiometricsService extends BiometricsService {
async supportsBiometric(): Promise<boolean> {
async authenticateWithBiometrics(): Promise<boolean> {
return false;
}
async isBiometricUnlockAvailable(): Promise<boolean> {
async getBiometricsStatus(): Promise<BiometricsStatus> {
return BiometricsStatus.PlatformUnsupported;
}
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
return null;
}
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return BiometricsStatus.PlatformUnsupported;
}
async getShouldAutopromptNow(): Promise<boolean> {
return false;
}
async authenticateBiometric(): Promise<boolean> {
throw new Error("Method not implemented.");
}
async biometricsNeedsSetup(): Promise<boolean> {
throw new Error("Method not implemented.");
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
throw new Error("Method not implemented.");
}
async biometricsSetup(): Promise<void> {
throw new Error("Method not implemented.");
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
}