1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-15 20:11:30 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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> {}
}

View File

@ -279,12 +279,13 @@ import {
ImportServiceAbstraction,
} from "@bitwarden/importer/core";
import {
KeyService as KeyServiceAbstraction,
DefaultKeyService as KeyService,
KeyService,
DefaultKeyService,
BiometricStateService,
DefaultBiometricStateService,
KdfConfigService,
BiometricsService,
DefaultKdfConfigService,
KdfConfigService,
UserAsymmetricKeysRegenerationService,
DefaultUserAsymmetricKeysRegenerationService,
UserAsymmetricKeysRegenerationApiService,
@ -416,7 +417,7 @@ const safeProviders: SafeProvider[] = [
deps: [
AccountServiceAbstraction,
MessagingServiceAbstraction,
KeyServiceAbstraction,
KeyService,
ApiServiceAbstraction,
StateServiceAbstraction,
TokenServiceAbstraction,
@ -428,7 +429,7 @@ const safeProviders: SafeProvider[] = [
deps: [
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyServiceAbstraction,
KeyService,
ApiServiceAbstraction,
TokenServiceAbstraction,
AppIdServiceAbstraction,
@ -471,7 +472,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: CipherServiceAbstraction,
useFactory: (
keyService: KeyServiceAbstraction,
keyService: KeyService,
domainSettingsService: DomainSettingsService,
apiService: ApiServiceAbstraction,
i18nService: I18nServiceAbstraction,
@ -501,7 +502,7 @@ const safeProviders: SafeProvider[] = [
accountService,
),
deps: [
KeyServiceAbstraction,
KeyService,
DomainSettingsService,
ApiServiceAbstraction,
I18nServiceAbstraction,
@ -520,7 +521,7 @@ const safeProviders: SafeProvider[] = [
provide: InternalFolderService,
useClass: FolderService,
deps: [
KeyServiceAbstraction,
KeyService,
EncryptService,
I18nServiceAbstraction,
CipherServiceAbstraction,
@ -565,7 +566,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: CollectionService,
useClass: DefaultCollectionService,
deps: [KeyServiceAbstraction, EncryptService, I18nServiceAbstraction, StateProvider],
deps: [KeyService, EncryptService, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: ENV_ADDITIONAL_REGIONS,
@ -610,8 +611,8 @@ const safeProviders: SafeProvider[] = [
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
provide: KeyServiceAbstraction,
useClass: KeyService,
provide: KeyService,
useClass: DefaultKeyService,
deps: [
PinServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
@ -636,7 +637,7 @@ const safeProviders: SafeProvider[] = [
useFactory: legacyPasswordGenerationServiceFactory,
deps: [
EncryptService,
KeyServiceAbstraction,
KeyService,
PolicyServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
@ -645,7 +646,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: GeneratorHistoryService,
useClass: LocalGeneratorHistoryService,
deps: [EncryptService, KeyServiceAbstraction, StateProvider],
deps: [EncryptService, KeyService, StateProvider],
}),
safeProvider({
provide: UsernameGenerationServiceAbstraction,
@ -653,7 +654,7 @@ const safeProviders: SafeProvider[] = [
deps: [
ApiServiceAbstraction,
I18nServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
PolicyServiceAbstraction,
AccountServiceAbstraction,
@ -693,7 +694,7 @@ const safeProviders: SafeProvider[] = [
provide: InternalSendService,
useClass: SendService,
deps: [
KeyServiceAbstraction,
KeyService,
I18nServiceAbstraction,
KeyGenerationServiceAbstraction,
SendStateProviderAbstraction,
@ -720,7 +721,7 @@ const safeProviders: SafeProvider[] = [
DomainSettingsService,
InternalFolderService,
CipherServiceAbstraction,
KeyServiceAbstraction,
KeyService,
CollectionService,
MessagingServiceAbstraction,
InternalPolicyService,
@ -753,7 +754,7 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
PinServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
KeyServiceAbstraction,
KeyService,
TokenServiceAbstraction,
PolicyServiceAbstraction,
BiometricStateService,
@ -780,6 +781,7 @@ const safeProviders: SafeProvider[] = [
StateEventRunnerService,
TaskSchedulerService,
LogService,
BiometricsService,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
],
@ -826,7 +828,7 @@ const safeProviders: SafeProvider[] = [
ImportApiServiceAbstraction,
I18nServiceAbstraction,
CollectionService,
KeyServiceAbstraction,
KeyService,
EncryptService,
PinServiceAbstraction,
AccountServiceAbstraction,
@ -839,7 +841,7 @@ const safeProviders: SafeProvider[] = [
FolderServiceAbstraction,
CipherServiceAbstraction,
PinServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
KdfConfigService,
@ -853,7 +855,7 @@ const safeProviders: SafeProvider[] = [
CipherServiceAbstraction,
ApiServiceAbstraction,
PinServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
CryptoFunctionServiceAbstraction,
CollectionService,
@ -960,7 +962,7 @@ const safeProviders: SafeProvider[] = [
deps: [
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyServiceAbstraction,
KeyService,
ApiServiceAbstraction,
TokenServiceAbstraction,
LogService,
@ -974,17 +976,15 @@ const safeProviders: SafeProvider[] = [
provide: UserVerificationServiceAbstraction,
useClass: UserVerificationService,
deps: [
KeyServiceAbstraction,
KeyService,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
I18nServiceAbstraction,
UserVerificationApiServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
PinServiceAbstraction,
LogService,
VaultTimeoutSettingsServiceAbstraction,
PlatformUtilsServiceAbstraction,
KdfConfigService,
BiometricsService,
],
}),
safeProvider({
@ -1007,7 +1007,7 @@ const safeProviders: SafeProvider[] = [
deps: [
OrganizationApiServiceAbstraction,
AccountServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
OrganizationUserApiService,
I18nServiceAbstraction,
@ -1117,7 +1117,7 @@ const safeProviders: SafeProvider[] = [
deps: [
KeyGenerationServiceAbstraction,
CryptoFunctionServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
AppIdServiceAbstraction,
DevicesApiServiceAbstraction,
@ -1137,7 +1137,7 @@ const safeProviders: SafeProvider[] = [
AppIdServiceAbstraction,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
ApiServiceAbstraction,
StateProvider,
@ -1231,7 +1231,7 @@ const safeProviders: SafeProvider[] = [
ApiServiceAbstraction,
BillingApiServiceAbstraction,
ConfigService,
KeyServiceAbstraction,
KeyService,
EncryptService,
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
@ -1291,7 +1291,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: UserAutoUnlockKeyService,
useClass: UserAutoUnlockKeyService,
deps: [KeyServiceAbstraction],
deps: [KeyService],
}),
safeProvider({
provide: ErrorHandler,
@ -1335,7 +1335,7 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSetPasswordJitService,
deps: [
ApiServiceAbstraction,
KeyServiceAbstraction,
KeyService,
EncryptService,
I18nServiceAbstraction,
KdfConfigService,
@ -1363,7 +1363,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: RegistrationFinishServiceAbstraction,
useClass: DefaultRegistrationFinishService,
deps: [KeyServiceAbstraction, AccountApiServiceAbstraction],
deps: [KeyService, AccountApiServiceAbstraction],
}),
safeProvider({
provide: ViewCacheService,
@ -1390,7 +1390,7 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsServiceAbstraction,
AccountServiceAbstraction,
KdfConfigService,
KeyServiceAbstraction,
KeyService,
],
}),
safeProvider({
@ -1418,7 +1418,7 @@ const safeProviders: SafeProvider[] = [
provide: UserAsymmetricKeysRegenerationService,
useClass: DefaultUserAsymmetricKeysRegenerationService,
deps: [
KeyServiceAbstraction,
KeyService,
CipherServiceAbstraction,
UserAsymmetricKeysRegenerationApiService,
LogService,

View File

@ -7,14 +7,17 @@ import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { KdfConfig, KeyService } from "@bitwarden/key-management";
import {
BiometricsService,
BiometricsStatus,
KdfConfig,
KeyService,
} from "@bitwarden/key-management";
import { KdfConfigService } from "../../../../../key-management/src/abstractions/kdf-config.service";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { VaultTimeoutSettingsService } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { HashPurpose } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
@ -36,10 +39,9 @@ describe("UserVerificationService", () => {
const userVerificationApiService = mock<UserVerificationApiServiceAbstraction>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const pinService = mock<PinServiceAbstraction>();
const logService = mock<LogService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const platformUtilsService = mock<PlatformUtilsService>();
const kdfConfigService = mock<KdfConfigService>();
const biometricsService = mock<BiometricsService>();
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
@ -56,10 +58,8 @@ describe("UserVerificationService", () => {
userVerificationApiService,
userDecryptionOptionsService,
pinService,
logService,
vaultTimeoutSettingsService,
platformUtilsService,
kdfConfigService,
biometricsService,
);
});
@ -113,26 +113,15 @@ describe("UserVerificationService", () => {
);
test.each([
[true, true, true, true],
[true, true, true, false],
[true, true, false, false],
[false, true, false, true],
[false, false, false, false],
[false, false, true, false],
[false, false, false, true],
[true, BiometricsStatus.Available],
[false, BiometricsStatus.DesktopDisconnected],
[false, BiometricsStatus.HardwareUnavailable],
])(
"returns %s for biometrics availability when isBiometricLockSet is %s, hasUserKeyStored is %s, and supportsSecureStorage is %s",
async (
expectedReturn: boolean,
isBiometricsLockSet: boolean,
isBiometricsUserKeyStored: boolean,
platformSupportSecureStorage: boolean,
) => {
async (expectedReturn: boolean, biometricsStatus: BiometricsStatus) => {
setMasterPasswordAvailability(false);
setPinAvailability("DISABLED");
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(isBiometricsLockSet);
keyService.hasUserKeyStored.mockResolvedValue(isBiometricsUserKeyStored);
platformUtilsService.supportsSecureStorage.mockReturnValue(platformSupportSecureStorage);
biometricsService.getBiometricsStatus.mockResolvedValue(biometricsStatus);
const result = await sut.getAvailableVerificationOptions("client");

View File

@ -3,17 +3,17 @@
import { firstValueFrom, map } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
BiometricsService,
BiometricsStatus,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin.service.abstraction";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { HashPurpose } from "../../../platform/enums";
import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { AccountService } from "../../abstractions/account.service";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
@ -47,10 +47,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
private userVerificationApiService: UserVerificationApiServiceAbstraction,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private pinService: PinServiceAbstraction,
private logService: LogService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private kdfConfigService: KdfConfigService,
private biometricsService: BiometricsService,
) {}
async getAvailableVerificationOptions(
@ -58,17 +56,13 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
): Promise<UserVerificationOptions> {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (verificationType === "client") {
const [
userHasMasterPassword,
isPinDecryptionAvailable,
biometricsLockSet,
biometricsUserKeyStored,
] = await Promise.all([
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.isPinDecryptionAvailable(userId),
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
]);
const [userHasMasterPassword, isPinDecryptionAvailable, biometricsStatus] = await Promise.all(
[
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.isPinDecryptionAvailable(userId),
this.biometricsService.getBiometricsStatus(),
],
);
// note: we do not need to check this.platformUtilsService.supportsBiometric() because
// we can just use the logic below which works for both desktop & the browser extension.
@ -77,9 +71,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
client: {
masterPassword: userHasMasterPassword,
pin: isPinDecryptionAvailable,
biometrics:
biometricsLockSet &&
(biometricsUserKeyStored || !this.platformUtilsService.supportsSecureStorage()),
biometrics: biometricsStatus === BiometricsStatus.Available,
},
server: {
masterPassword: false,
@ -253,17 +245,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
private async verifyUserByBiometrics(): Promise<boolean> {
let userKey: UserKey;
// Biometrics crashes and doesn't return a value if the user cancels the prompt
try {
userKey = await this.keyService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
} catch (e) {
this.logService.error(`Biometrics User Verification failed: ${e.message}`);
// So, any failures should be treated as a failed verification
return false;
}
return userKey != null;
return this.biometricsService.authenticateWithBiometrics();
}
async requestOTP() {

View File

@ -2,6 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom, map, timeout } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BiometricStateService } from "@bitwarden/key-management";
@ -24,6 +25,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private biometricStateService: BiometricStateService,
private accountService: AccountService,
private logService: LogService,
) {}
async startProcessReload(authService: AuthService): Promise<void> {

View File

@ -1,5 +1,4 @@
export enum KeySuffixOptions {
Auto = "auto",
Biometric = "biometric",
Pin = "pin",
}

View File

@ -5,6 +5,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { LogoutReason } from "@bitwarden/auth/common";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { BiometricsService } from "@bitwarden/key-management";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { SearchService } from "../../abstractions/search.service";
@ -41,6 +42,7 @@ describe("VaultTimeoutService", () => {
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let logService: MockProxy<LogService>;
let biometricsService: MockProxy<BiometricsService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
@ -66,6 +68,7 @@ describe("VaultTimeoutService", () => {
stateEventRunnerService = mock();
taskSchedulerService = mock<TaskSchedulerService>();
logService = mock<LogService>();
biometricsService = mock<BiometricsService>();
lockedCallback = jest.fn();
loggedOutCallback = jest.fn();
@ -93,6 +96,7 @@ describe("VaultTimeoutService", () => {
stateEventRunnerService,
taskSchedulerService,
logService,
biometricsService,
lockedCallback,
loggedOutCallback,
);

View File

@ -6,6 +6,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { LogoutReason } from "@bitwarden/auth/common";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
import { BiometricsService } from "@bitwarden/key-management";
import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@ -41,6 +42,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private stateEventRunnerService: StateEventRunnerService,
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
@ -98,6 +100,8 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
}
async lock(userId?: UserId): Promise<void> {
await this.biometricService.setShouldAutopromptNow(false);
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
if (!authed) {
return;

View File

@ -3,8 +3,4 @@
*/
export { LockComponent } from "./lock/components/lock.component";
export {
LockComponentService,
BiometricsDisableReason,
UnlockOptions,
} from "./lock/services/lock-component.service";
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";

View File

@ -86,12 +86,13 @@
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.biometrics.enabled">
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
@ -156,12 +157,13 @@
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="unlockOptions.biometrics.enabled">
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>

View File

@ -4,7 +4,16 @@ import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs";
import {
BehaviorSubject,
firstValueFrom,
interval,
mergeMap,
Subject,
switchMap,
take,
takeUntil,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
@ -27,7 +36,6 @@ 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserKey } from "@bitwarden/common/types/key";
@ -42,6 +50,8 @@ import {
import {
KeyService,
BiometricStateService,
BiometricsService,
BiometricsStatus,
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
@ -115,9 +125,6 @@ export class LockComponent implements OnInit, OnDestroy {
private deferFocus: boolean = null;
private biometricAsked = false;
// Browser extension properties:
private isInitialLockScreen = (window as any).previousPopupUrl == null;
defaultUnlockOptionSetForUser = false;
unlockingViaBiometrics = false;
@ -144,6 +151,8 @@ export class LockComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private biometricService: BiometricsService,
private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
@ -157,14 +166,31 @@ export class LockComponent implements OnInit, OnDestroy {
// Listen for active account changes
this.listenForActiveAccountChanges();
this.listenForUnlockOptionsChanges();
// Identify client
this.clientType = this.platformUtilsService.getClientType();
if (this.clientType === "desktop") {
await this.desktopOnInit();
} else if (this.clientType === ClientType.Browser) {
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
}
}
private listenForUnlockOptionsChanges() {
interval(1000)
.pipe(
mergeMap(async () => {
this.unlockOptions = await firstValueFrom(
this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id),
);
}),
takeUntil(this.destroy$),
)
.subscribe();
}
// Base component methods
private listenForActiveUnlockOptionChanges() {
this.activeUnlockOption$
@ -234,7 +260,6 @@ export class LockComponent implements OnInit, OnDestroy {
this.unlockOptions = null;
this.activeUnlockOption = null;
this.formGroup = null; // new form group will be created based on new active unlock option
this.isInitialLockScreen = true;
// Desktop properties:
this.biometricAsked = false;
@ -276,8 +301,9 @@ export class LockComponent implements OnInit, OnDestroy {
if (
this.unlockOptions.biometrics.enabled &&
autoPromptBiometrics &&
this.isInitialLockScreen // only autoprompt biometrics on initial lock screen
(await this.biometricService.getShouldAutopromptNow())
) {
await this.biometricService.setShouldAutopromptNow(false);
await this.unlockViaBiometrics();
}
}
@ -316,8 +342,7 @@ export class LockComponent implements OnInit, OnDestroy {
try {
await this.biometricStateService.setUserPromptCancelled();
const userKey = await this.keyService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
const userKey = await this.biometricService.unlockWithBiometricsForUser(
this.activeAccount.id,
);
@ -587,6 +612,8 @@ export class LockComponent implements OnInit, OnDestroy {
// -----------------------------------------------------------------------------------------------
async desktopOnInit() {
this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText();
// TODO: move this into a WindowService and subscribe to messages via MessageListener service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
@ -617,6 +644,10 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
if (!(await this.biometricService.getShouldAutopromptNow())) {
return;
}
// prevent the biometric prompt from showing if the user has already cancelled it
if (await firstValueFrom(this.biometricStateService.promptCancelled$)) {
return;
@ -650,4 +681,47 @@ export class LockComponent implements OnInit, OnDestroy {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
}
}
get biometricsAvailable(): boolean {
return this.unlockOptions.biometrics.enabled;
}
get showBiometrics(): boolean {
return (
this.unlockOptions.biometrics.biometricsStatus !== BiometricsStatus.PlatformUnsupported &&
this.unlockOptions.biometrics.biometricsStatus !== BiometricsStatus.NotEnabledLocally
);
}
get biometricUnavailabilityReason(): string {
switch (this.unlockOptions.biometrics.biometricsStatus) {
case BiometricsStatus.Available:
return "";
case BiometricsStatus.UnlockNeeded:
return this.i18nService.t("biometricsStatusHelptextUnlockNeeded");
case BiometricsStatus.HardwareUnavailable:
return this.i18nService.t("biometricsStatusHelptextHardwareUnavailable");
case BiometricsStatus.AutoSetupNeeded:
return this.i18nService.t("biometricsStatusHelptextAutoSetupNeeded");
case BiometricsStatus.ManualSetupNeeded:
return this.i18nService.t("biometricsStatusHelptextManualSetupNeeded");
case BiometricsStatus.NotEnabledInConnectedDesktopApp:
return this.i18nService.t(
"biometricsStatusHelptextNotEnabledInDesktop",
this.activeAccount.email,
);
case BiometricsStatus.NotEnabledLocally:
return this.i18nService.t(
"biometricsStatusHelptextNotEnabledInDesktop",
this.activeAccount.email,
);
case BiometricsStatus.DesktopDisconnected:
return this.i18nService.t("biometricsStatusHelptextDesktopDisconnected");
default:
return (
this.i18nService.t("biometricsStatusHelptextUnavailableReasonUnknown") +
this.unlockOptions.biometrics.biometricsStatus
);
}
}
}

View File

@ -1,12 +1,7 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
export enum BiometricsDisableReason {
NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem",
EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable",
SystemBiometricsUnavailable = "SystemBiometricsUnavailable",
}
import { BiometricsStatus } from "@bitwarden/key-management";
// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics"
export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption];
@ -26,7 +21,7 @@ export type UnlockOptions = {
};
biometrics: {
enabled: boolean;
disableReason: BiometricsDisableReason | null;
biometricsStatus: BiometricsStatus;
};
};

View File

@ -1,37 +1,42 @@
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { BiometricsStatus } from "./biometrics-status";
/**
* The biometrics service is used to provide access to the status of and access to biometric functionality on the platforms.
*/
export abstract class BiometricsService {
supportsBiometric() {
throw new Error("Method not implemented.");
}
/**
* Check if the platform supports biometric authentication.
* Performs a biometric prompt, without unlocking any keys
* @returns true if the biometric prompt was successful, false otherwise
*/
abstract supportsBiometric(): Promise<boolean>;
abstract authenticateWithBiometrics(): Promise<boolean>;
/**
* Checks whether biometric unlock is currently available at the moment (e.g. if the laptop lid is shut, biometric unlock may not be available)
* Gets the status of biometrics for the platform system states.
* @returns the status of biometrics
*/
abstract isBiometricUnlockAvailable(): Promise<boolean>;
abstract getBiometricsStatus(): Promise<BiometricsStatus>;
/**
* Performs biometric authentication
* Retrieves a userkey for the provided user, as present in the biometrics system.
* THIS NEEDS TO BE VERIFIED FOR RECENCY AND VALIDITY
* @param userId the user to unlock
* @returns the user key
*/
abstract authenticateBiometric(): Promise<boolean>;
abstract unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null>;
/**
* Determine whether biometrics support requires going through a setup process.
* This is currently only needed on Linux.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
* Gets the status of biometrics for a current user. This includes system states (hardware unavailable) but also user specific states (needs unlock with master-password).
* @param userId the user to check the biometrics status for
* @returns the status of biometrics for the user
*/
abstract biometricsNeedsSetup(): Promise<boolean>;
/**
* Determine whether biometrics support can be automatically setup, or requires user interaction.
* Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
abstract biometricsSupportsAutoSetup(): Promise<boolean>;
/**
* Start automatic biometric setup, which places the required configuration files / changes the required settings.
*/
abstract biometricsSetup(): Promise<void>;
abstract getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus>;
abstract getShouldAutopromptNow(): Promise<boolean>;
abstract setShouldAutopromptNow(value: boolean): Promise<void>;
}

View File

@ -0,0 +1,14 @@
export enum BiometricsCommands {
/** Perform biometric authentication for the system's user. Does not require setup, and does not return cryptographic material, only yes or no. */
AuthenticateWithBiometrics = "authenticateWithBiometrics",
/** Get biometric status of the system, and can be used before biometrics is set up. Only returns data about the biometrics system, not about availability of cryptographic material */
GetBiometricsStatus = "getBiometricsStatus",
/** Perform biometric authentication for the system's user for the given bitwarden account's credentials. This returns cryptographic material that can be used to unlock the vault. */
UnlockWithBiometricsForUser = "unlockWithBiometricsForUser",
/** Get biometric status for a specific user account. This includes both information about availability of cryptographic material (is the user configured for biometric unlock? is a masterpassword unlock needed? But also information about the biometric system's availability in a single status) */
GetBiometricsStatusForUser = "getBiometricsStatusForUser",
// legacy
Unlock = "biometricUnlock",
IsAvailable = "biometricUnlockAvailable",
}

View File

@ -0,0 +1,22 @@
export enum BiometricsStatus {
/** For the biometrics interface, this means that biometric unlock is available and can be used. Querying for the user specifically, this means that biometric can be used for to unlock this user */
Available,
/** Biometrics cannot be used, because the userkey needs to first be unlocked by the user's password, because unlock needs some volatile data that is not available on app-start */
UnlockNeeded,
/** Biometric hardware is not available (i.e laptop folded shut, sensor unplugged) */
HardwareUnavailable,
/** Only relevant for linux, this means that polkit policies need to be set up and that can happen automatically */
AutoSetupNeeded,
/** Only relevant for linux, this means that polkit policies need to be set up but that needs to be done manually */
ManualSetupNeeded,
/** Biometrics is not implemented for this platform (i.e web) */
PlatformUnsupported,
/** Browser extension cannot connect to the desktop app to use biometrics */
DesktopDisconnected,
/** Biometrics is not enabled in the desktop app/extension (current app) */
NotEnabledLocally,
/** Only on browser extension; Biometrics is not enabled in the desktop app */
NotEnabledInConnectedDesktopApp,
/** Browser extension does not have the permission to talk to the desktop app */
NativeMessagingPermissionMissing,
}

View File

@ -2,6 +2,8 @@ export {
BiometricStateService,
DefaultBiometricStateService,
} from "./biometrics/biometric-state.service";
export { BiometricsStatus } from "./biometrics/biometrics-status";
export { BiometricsCommands } from "./biometrics/biometrics-commands";
export { BiometricsService } from "./biometrics/biometric.service";
export * from "./biometrics/biometric.state";