diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 85937b63304..55c9ae8616b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/browser/src/auth/popup/account-switching/account.component.ts b/apps/browser/src/auth/popup/account-switching/account.component.ts index 104241e9c7b..dad74977d34 100644 --- a/apps/browser/src/auth/popup/account-switching/account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account.component.ts @@ -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); } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index e0dfde7be77..3f874fc1a76 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -11,13 +11,16 @@

{{ "unlockMethods" | i18n }}

- + {{ "unlockWithBiometrics" | i18n }} + + {{ biometricUnavailabilityReason }} + - + { + 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; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bcfa797e0ff..4bec3d6cc0a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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 diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 2ded1760235..116d048d2e8 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -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(); + + 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 { + 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 { - 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)); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index c31ec94be90..75340e3fbc3 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -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( diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index 0cd48c45938..8e6fc562d14 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -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 { - const responsePromise = this.nativeMessagingBackground().getResponse(); - await this.nativeMessagingBackground().send({ command: "biometricUnlock" }); - const response = await responsePromise; - return response.response === "unlocked"; + async authenticateWithBiometrics(): Promise { + 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 { - const responsePromise = this.nativeMessagingBackground().getResponse(); - await this.nativeMessagingBackground().send({ command: "biometricUnlockAvailable" }); - const response = await responsePromise; - return response.response === "available"; + async getBiometricsStatus(): Promise { + 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 { + async unlockWithBiometricsForUser(userId: UserId): Promise { + 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 { + 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 { return false; } - async biometricsSupportsAutoSetup(): Promise { - return false; - } - - async biometricsSetup(): Promise {} + async setShouldAutopromptNow(value: boolean): Promise {} } diff --git a/apps/browser/src/key-management/biometrics/browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/browser-biometrics.service.ts deleted file mode 100644 index 7ffbed45415..00000000000 --- a/apps/browser/src/key-management/biometrics/browser-biometrics.service.ts +++ /dev/null @@ -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; - abstract isBiometricUnlockAvailable(): Promise; -} diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts index f50468c8b7a..0235ad5bd9c 100644 --- a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts @@ -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 { + async authenticateWithBiometrics(): Promise { const response = await BrowserApi.sendMessageWithResponse<{ result: boolean; error: string; - }>("biometricUnlock"); + }>(BiometricsCommands.AuthenticateWithBiometrics); if (!response.result) { throw response.error; } return response.result; } - async isBiometricUnlockAvailable(): Promise { + async getBiometricsStatus(): Promise { 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 { - return false; + async unlockWithBiometricsForUser(userId: UserId): Promise { + 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 { - return false; + async getBiometricsStatusForUser(id: UserId): Promise { + const response = await BrowserApi.sendMessageWithResponse<{ + result: BiometricsStatus; + error: string; + }>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id }); + return response.result; } - async biometricsSetup(): Promise {} + async getShouldAutopromptNow(): Promise { + return this.shouldAutopromptNow; + } + async setShouldAutopromptNow(value: boolean): Promise { + this.shouldAutopromptNow = value; + } } diff --git a/apps/browser/src/key-management/browser-key.service.ts b/apps/browser/src/key-management/browser-key.service.ts deleted file mode 100644 index 0cc5f13a27e..00000000000 --- a/apps/browser/src/key-management/browser-key.service.ts +++ /dev/null @@ -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 { - 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 { - 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); - } -} diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 272201c6ede..4b0323d5ebe 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -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, diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 07fb2ec6b87..f21beb91cff 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -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 { - 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 { 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; + }), ); } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 6542eb9c814..0fb21732fdd 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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, ], }), diff --git a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index 1768ce6b15f..58d95f959be 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -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 +} diff --git a/apps/cli/src/key-management/cli-biometrics-service.ts b/apps/cli/src/key-management/cli-biometrics-service.ts new file mode 100644 index 00000000000..bda8fe82895 --- /dev/null +++ b/apps/cli/src/key-management/cli-biometrics-service.ts @@ -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 { + return false; + } + + async getBiometricsStatus(): Promise { + return BiometricsStatus.PlatformUnsupported; + } + + async unlockWithBiometricsForUser(userId: UserId): Promise { + return null; + } + + async getBiometricsStatusForUser(userId: UserId): Promise { + return BiometricsStatus.PlatformUnsupported; + } + + async getShouldAutopromptNow(): Promise { + return false; + } + + async setShouldAutopromptNow(value: boolean): Promise {} +} diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index bef4d52fad5..f57db9909d6 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -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, ); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index c27ca240d3f..19748e797bb 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -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() { diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index cbd0dcf78aa..db8c2a85bde 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -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( diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 87c2a833073..8b890032443 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -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({ diff --git a/apps/desktop/src/key-management/biometrics/biometric.noop.main.ts b/apps/desktop/src/key-management/biometrics/biometric.noop.main.ts deleted file mode 100644 index 57a86942e8c..00000000000 --- a/apps/desktop/src/key-management/biometrics/biometric.noop.main.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { OsBiometricService } from "./desktop.biometrics.service"; - -export default class NoopBiometricsService implements OsBiometricService { - constructor() {} - - async init() {} - - async osSupportsBiometric(): Promise { - return false; - } - - async osBiometricsNeedsSetup(): Promise { - return false; - } - - async osBiometricsCanAutoSetup(): Promise { - return false; - } - - async osBiometricsSetup(): Promise {} - - async getBiometricKey( - service: string, - storageKey: string, - clientKeyHalfB64: string, - ): Promise { - return null; - } - - async setBiometricKey( - service: string, - storageKey: string, - value: string, - clientKeyPartB64: string | undefined, - ): Promise { - return; - } - - async deleteBiometricKey(service: string, key: string): Promise {} - - async authenticateBiometric(): Promise { - throw new Error("Not supported on this platform"); - } -} diff --git a/apps/desktop/src/key-management/biometrics/biometric.renderer-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/biometric.renderer-ipc.listener.ts deleted file mode 100644 index a057deca54f..00000000000 --- a/apps/desktop/src/key-management/biometrics/biometric.renderer-ipc.listener.ts +++ /dev/null @@ -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); - } - }); - } -} diff --git a/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts index d2ed648ba65..e69ebca3630 100644 --- a/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/biometrics.service.spec.ts @@ -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(); 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(); - (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; - 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); + } }); }); }); diff --git a/apps/desktop/src/key-management/biometrics/biometrics.service.ts b/apps/desktop/src/key-management/biometrics/biometrics.service.ts deleted file mode 100644 index 3867412d884..00000000000 --- a/apps/desktop/src/key-management/biometrics/biometrics.service.ts +++ /dev/null @@ -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(); - - 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 { - 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 { - 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 { - return await this.platformSpecificService.osSupportsBiometric(); - } - - async getBiometricKey(service: string, storageKey: string): Promise { - 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 { - 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 { - 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 { - this.clientKeyHalves.delete(this.clientKeyHalfKey(service, storageKey)); - return await this.platformSpecificService.deleteBiometricKey(service, storageKey); - } - - private async interruptProcessReload( - callback: () => Promise, - restartReloadCallback: (arg: T) => boolean = () => false, - ): Promise { - 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 { - // 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."); - } - } -} diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index eee3e5fc7f3..0c0efea78f9 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -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; - abstract getBiometricKey(service: string, key: string): Promise; - abstract setBiometricKey(service: string, key: string, value: string): Promise; - abstract setEncryptionKeyHalf({ - service, - key, - value, - }: { - service: string; - key: string; - value: string; - }): void; - abstract deleteBiometricKey(service: string, key: string): Promise; -} + abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise; + abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise; -export interface OsBiometricService { - osSupportsBiometric(): Promise; - /** - * 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; - /** - * 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; - /** - * Starts automatic biometric setup, which places the required configuration files / changes the required settings. - */ - osBiometricsSetup: () => Promise; - authenticateBiometric(): Promise; - getBiometricKey( - service: string, - key: string, - clientKeyHalfB64: string | undefined, - ): Promise; - setBiometricKey( - service: string, - key: string, - value: string, - clientKeyHalfB64: string | undefined, - ): Promise; - deleteBiometricKey(service: string, key: string): Promise; + abstract setupBiometrics(): Promise; + + abstract setClientKeyHalfForUser(userId: UserId, value: string): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/electron-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/electron-biometrics.service.ts deleted file mode 100644 index 226c914e6ff..00000000000 --- a/apps/desktop/src/key-management/biometrics/electron-biometrics.service.ts +++ /dev/null @@ -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 { - return await ipc.keyManagement.biometric.osSupported(); - } - - async isBiometricUnlockAvailable(): Promise { - 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 { - return await ipc.keyManagement.biometric.authenticate(); - } - - async biometricsNeedsSetup(): Promise { - return await ipc.keyManagement.biometric.biometricsNeedsSetup(); - } - - async biometricsSupportsAutoSetup(): Promise { - return await ipc.keyManagement.biometric.biometricsCanAutoSetup(); - } - - async biometricsSetup(): Promise { - return await ipc.keyManagement.biometric.biometricsSetup(); - } -} diff --git a/apps/desktop/src/key-management/biometrics/index.ts b/apps/desktop/src/key-management/biometrics/index.ts deleted file mode 100644 index ad7725d718a..00000000000 --- a/apps/desktop/src/key-management/biometrics/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./desktop.biometrics.service"; -export * from "./biometrics.service"; diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts new file mode 100644 index 00000000000..eebafd8d48b --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -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); + } + }); + } +} diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts new file mode 100644 index 00000000000..06956503a05 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -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(); + 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 { + 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 { + 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 { + return await this.osBiometricsService.authenticateBiometric(); + } + + async setupBiometrics(): Promise { + return await this.osBiometricsService.osBiometricsSetup(); + } + + async setClientKeyHalfForUser(userId: UserId, value: string): Promise { + this.clientKeyHalves.set(userId, value); + } + + async authenticateWithBiometrics(): Promise { + return await this.osBiometricsService.authenticateBiometric(); + } + + async unlockWithBiometricsForUser(userId: UserId): Promise { + 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 { + 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 { + 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 { + 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 { + return this.shouldAutoPrompt; + } +} diff --git a/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts similarity index 97% rename from apps/desktop/src/key-management/biometrics/biometric.unix.main.ts rename to apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index f2bcf62e03e..791b4d6f885 100644 --- a/apps/desktop/src/key-management/biometrics/biometric.unix.main.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -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 = ` 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, diff --git a/apps/desktop/src/key-management/biometrics/biometric.darwin.main.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts similarity index 92% rename from apps/desktop/src/key-management/biometrics/biometric.darwin.main.ts rename to apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts index 0f26cc78fbf..e361084726a 100644 --- a/apps/desktop/src/key-management/biometrics/biometric.darwin.main.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts @@ -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 { diff --git a/apps/desktop/src/key-management/biometrics/biometric.windows.main.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts similarity index 93% rename from apps/desktop/src/key-management/biometrics/biometric.windows.main.ts rename to apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index 0b0ad8c4500..9643c2b6f15 100644 --- a/apps/desktop/src/key-management/biometrics/biometric.windows.main.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -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() { diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts new file mode 100644 index 00000000000..f5132200149 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts @@ -0,0 +1,32 @@ +export interface OsBiometricService { + osSupportsBiometric(): Promise; + /** + * 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; + /** + * 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; + /** + * Starts automatic biometric setup, which places the required configuration files / changes the required settings. + */ + osBiometricsSetup: () => Promise; + authenticateBiometric(): Promise; + getBiometricKey( + service: string, + key: string, + clientKeyHalfB64: string | undefined, + ): Promise; + setBiometricKey( + service: string, + key: string, + value: string, + clientKeyHalfB64: string | undefined, + ): Promise; + deleteBiometricKey(service: string, key: string): Promise; +} diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts new file mode 100644 index 00000000000..a08e68b53f2 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -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 { + return await ipc.keyManagement.biometric.authenticateWithBiometrics(); + } + + async getBiometricsStatus(): Promise { + return await ipc.keyManagement.biometric.getBiometricsStatus(); + } + + async unlockWithBiometricsForUser(userId: UserId): Promise { + return await ipc.keyManagement.biometric.unlockWithBiometricsForUser(userId); + } + + async getBiometricsStatusForUser(id: UserId): Promise { + return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id); + } + + async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise { + return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value); + } + + async deleteBiometricUnlockKeyForUser(userId: UserId): Promise { + return await ipc.keyManagement.biometric.deleteBiometricUnlockKeyForUser(userId); + } + + async setupBiometrics(): Promise { + return await ipc.keyManagement.biometric.setupBiometrics(); + } + + async setClientKeyHalfForUser(userId: UserId, value: string): Promise { + return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value); + } + + async getShouldAutopromptNow(): Promise { + return await ipc.keyManagement.biometric.getShouldAutoprompt(); + } + + async setShouldAutopromptNow(value: boolean): Promise { + return await ipc.keyManagement.biometric.setShouldAutoprompt(value); + } +} diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts index 2d60cdeb663..2cc8d770f58 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts @@ -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); diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts index 76232fd3196..1d2d68c1d97 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts @@ -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 { - 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 { 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; - } } diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index ffb6159a46f..b73542ca725 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -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 => + authenticateWithBiometrics: (): Promise => ipcRenderer.invoke("biometric", { - action: BiometricAction.EnabledForUser, - key: `${userId}_user_biometric`, - keySuffix: KeySuffixOptions.Biometric, + action: BiometricAction.Authenticate, + } satisfies BiometricMessage), + getBiometricsStatus: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.GetStatus, + } satisfies BiometricMessage), + unlockWithBiometricsForUser: (userId: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.UnlockForUser, userId: userId, } satisfies BiometricMessage), - osSupported: (): Promise => + getBiometricsStatusForUser: (userId: string): Promise => ipcRenderer.invoke("biometric", { - action: BiometricAction.OsSupported, + action: BiometricAction.GetStatusForUser, + userId: userId, } satisfies BiometricMessage), - biometricsNeedsSetup: (): Promise => + setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise => ipcRenderer.invoke("biometric", { - action: BiometricAction.NeedsSetup, + action: BiometricAction.SetKeyForUser, + userId: userId, + key: value, } satisfies BiometricMessage), - biometricsSetup: (): Promise => + deleteBiometricUnlockKeyForUser: (userId: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.RemoveKeyForUser, + userId: userId, + } satisfies BiometricMessage), + setupBiometrics: (): Promise => ipcRenderer.invoke("biometric", { action: BiometricAction.Setup, } satisfies BiometricMessage), - biometricsCanAutoSetup: (): Promise => + setClientKeyHalf: (userId: string, value: string): Promise => ipcRenderer.invoke("biometric", { - action: BiometricAction.CanAutoSetup, + action: BiometricAction.SetClientKeyHalf, + userId: userId, + key: value, } satisfies BiometricMessage), - authenticate: (): Promise => + getShouldAutoprompt: (): Promise => ipcRenderer.invoke("biometric", { - action: BiometricAction.Authenticate, + action: BiometricAction.GetShouldAutoprompt, + } satisfies BiometricMessage), + setShouldAutoprompt: (should: boolean): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.SetShouldAutoprompt, + data: should, } satisfies BiometricMessage), }; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 323d0cd3f7b..9ab15230604 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -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" }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a4842249c93..3232eef2b9b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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>>(); 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 () => { diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index 52a8615a1da..9fa7fe6143f 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -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) { diff --git a/apps/desktop/src/models/native-messaging/legacy-message.ts b/apps/desktop/src/models/native-messaging/legacy-message.ts index a2bcf2aa7e5..99047cdcd34 100644 --- a/apps/desktop/src/models/native-messaging/legacy-message.ts +++ b/apps/desktop/src/models/native-messaging/legacy-message.ts @@ -1,5 +1,6 @@ export type LegacyMessage = { command: string; + messageId: number; userId?: string; timestamp?: number; diff --git a/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts b/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts index 294f9a3cbe9..ca4d9a2d3ca 100644 --- a/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts +++ b/apps/desktop/src/platform/main/desktop-credential-storage-listener.ts @@ -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); } } diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 0b61d894776..9c1986fb61d 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -87,6 +87,7 @@ const nativeMessaging = { }, sendMessage: (message: { appId: string; + messageId?: number; command?: string; sharedSecret?: string; message?: EncString; diff --git a/apps/desktop/src/platform/services/electron-key.service.spec.ts b/apps/desktop/src/platform/services/electron-key.service.spec.ts deleted file mode 100644 index fc87ae4ceaf..00000000000 --- a/apps/desktop/src/platform/services/electron-key.service.spec.ts +++ /dev/null @@ -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(); - const keyGenerationService = mock(); - const cryptoFunctionService = mock(); - const encryptService = mock(); - const platformUtilService = mock(); - const logService = mock(); - const stateService = mock(); - let masterPasswordService: FakeMasterPasswordService; - let accountService: FakeAccountService; - let stateProvider: FakeStateProvider; - const biometricStateService = mock(); - const kdfConfigService = mock(); - - 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, - }); - }); - }); - }); -}); diff --git a/apps/desktop/src/platform/services/electron-key.service.ts b/apps/desktop/src/platform/services/electron-key.service.ts index a4719873375..9a18753e4b5 100644 --- a/apps/desktop/src/platform/services/electron-key.service.ts +++ b/apps/desktop/src/platform/services/electron-key.service.ts @@ -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 { - if (keySuffix === KeySuffixOptions.Biometric) { - return await this.stateService.hasUserKeyBiometric({ userId: userId }); - } return super.hasUserKeyStored(keySuffix, userId); } override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { - 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 { - 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 { + protected async storeBiometricsProtectedUserKey( + userKey: UserKey, + userId?: UserId, + ): Promise { // 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 { - 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 { - 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; } } diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts new file mode 100644 index 00000000000..13b668f6b83 --- /dev/null +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -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; + let keyService: MockProxy; + let encryptService: MockProxy; + let logService: MockProxy; + let messagingService: MockProxy; + let desktopSettingsService: DesktopSettingsService; + let biometricStateService: BiometricStateService; + let biometricsService: MockProxy; + let dialogService: MockProxy; + let accountService: AccountService; + let authService: MockProxy; + let ngZone: MockProxy; + + beforeEach(() => { + cryptoFunctionService = mock(); + keyService = mock(); + encryptService = mock(); + logService = mock(); + messagingService = mock(); + desktopSettingsService = mock(); + biometricStateService = mock(); + biometricsService = mock(); + dialogService = mock(); + + accountService = new FakeAccountService(accounts); + authService = mock(); + ngZone = mock(); + + 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(); + } + }, + ); + }); +}); diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 68b2e8f505c..ea1e7e76c56 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -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(); + } + } + } } diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 0db7b60a2df..7946280e9a6 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -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; }; diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 5eb26a8c76c..3c941fe24c7 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -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, }, }); }); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index dc124983c9a..02910966d6e 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -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; diff --git a/apps/web/src/app/key-management/web-biometric.service.ts b/apps/web/src/app/key-management/web-biometric.service.ts index 4681eb6fa49..0c58c0da759 100644 --- a/apps/web/src/app/key-management/web-biometric.service.ts +++ b/apps/web/src/app/key-management/web-biometric.service.ts @@ -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 { + async authenticateWithBiometrics(): Promise { return false; } - async isBiometricUnlockAvailable(): Promise { + async getBiometricsStatus(): Promise { + return BiometricsStatus.PlatformUnsupported; + } + + async unlockWithBiometricsForUser(userId: UserId): Promise { + return null; + } + + async getBiometricsStatusForUser(userId: UserId): Promise { + return BiometricsStatus.PlatformUnsupported; + } + + async getShouldAutopromptNow(): Promise { return false; } - async authenticateBiometric(): Promise { - throw new Error("Method not implemented."); - } - - async biometricsNeedsSetup(): Promise { - throw new Error("Method not implemented."); - } - - async biometricsSupportsAutoSetup(): Promise { - throw new Error("Method not implemented."); - } - - async biometricsSetup(): Promise { - throw new Error("Method not implemented."); - } + async setShouldAutopromptNow(value: boolean): Promise {} } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d990a7315f2..f5940b8e144 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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, diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts index 4aa3a632855..081dafb1706 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts @@ -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(); const userDecryptionOptionsService = mock(); const pinService = mock(); - const logService = mock(); const vaultTimeoutSettingsService = mock(); - const platformUtilsService = mock(); const kdfConfigService = mock(); + const biometricsService = mock(); 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"); diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 822ee70ec5b..2935c1958a4 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -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 { 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 { - 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() { diff --git a/libs/common/src/key-management/services/default-process-reload.service.ts b/libs/common/src/key-management/services/default-process-reload.service.ts index 961d199b06e..8c1d1117c89 100644 --- a/libs/common/src/key-management/services/default-process-reload.service.ts +++ b/libs/common/src/key-management/services/default-process-reload.service.ts @@ -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 { diff --git a/libs/common/src/platform/enums/key-suffix-options.enum.ts b/libs/common/src/platform/enums/key-suffix-options.enum.ts index b268c4b777f..98fa215be6a 100644 --- a/libs/common/src/platform/enums/key-suffix-options.enum.ts +++ b/libs/common/src/platform/enums/key-suffix-options.enum.ts @@ -1,5 +1,4 @@ export enum KeySuffixOptions { Auto = "auto", - Biometric = "biometric", Pin = "pin", } diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts index 1350010f849..8a166e63a1f 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -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; let taskSchedulerService: MockProxy; let logService: MockProxy; + let biometricsService: MockProxy; let lockedCallback: jest.Mock, [userId: string]>; let loggedOutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: string]>; @@ -66,6 +68,7 @@ describe("VaultTimeoutService", () => { stateEventRunnerService = mock(); taskSchedulerService = mock(); logService = mock(); + biometricsService = mock(); lockedCallback = jest.fn(); loggedOutCallback = jest.fn(); @@ -93,6 +96,7 @@ describe("VaultTimeoutService", () => { stateEventRunnerService, taskSchedulerService, logService, + biometricsService, lockedCallback, loggedOutCallback, ); diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index 55d5bffa99a..8ab10b44b24 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -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 = null, private loggedOutCallback: ( logoutReason: LogoutReason, @@ -98,6 +100,8 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } async lock(userId?: UserId): Promise { + await this.biometricService.setShouldAutopromptNow(false); + const authed = await this.stateService.getIsAuthenticated({ userId: userId }); if (!authed) { return; diff --git a/libs/key-management/src/angular/index.ts b/libs/key-management/src/angular/index.ts index d7fadc52ce6..1eb9b88b072 100644 --- a/libs/key-management/src/angular/index.ts +++ b/libs/key-management/src/angular/index.ts @@ -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"; diff --git a/libs/key-management/src/angular/lock/components/lock.component.html b/libs/key-management/src/angular/lock/components/lock.component.html index 5f5991c681e..7d9ed6124f6 100644 --- a/libs/key-management/src/angular/lock/components/lock.component.html +++ b/libs/key-management/src/angular/lock/components/lock.component.html @@ -86,12 +86,13 @@

{{ "or" | i18n }}

- +