From 341a93aa47f89c862030c963cf46b6338cf08907 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 22 Nov 2024 17:41:14 -0800 Subject: [PATCH] [Pm-9823] Extract biometric messaging service (#10862) --- .github/CODEOWNERS | 4 +- .../src/app/services/services.module.ts | 6 +- .../biometric-message-handler.service.ts | 238 +++++++++++++++++ ... => duckduckgo-message-handler.service.ts} | 16 +- .../src/services/native-messaging.service.ts | 239 +----------------- 5 files changed, 261 insertions(+), 242 deletions(-) create mode 100644 apps/desktop/src/services/biometric-message-handler.service.ts rename apps/desktop/src/services/{native-message-handler.service.ts => duckduckgo-message-handler.service.ts} (95%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c050ee1f6c0a..a90545ab57c4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -95,7 +95,8 @@ libs/common/src/autofill @bitwarden/team-autofill-dev apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev -apps/desktop/src/services/native-message-handler.service.ts @bitwarden/team-autofill-dev +apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev + ## Component Library ## .storybook @bitwarden/team-design-system @@ -116,6 +117,7 @@ libs/key-management @bitwarden/team-key-management-dev apps/desktop/destkop_native/core/src/biometric/ @bitwarden/team-key-management-dev apps/desktop/src/services/native-messaging.service.ts @bitwarden/team-key-management-dev apps/browser/src/background/nativeMessaging.background.ts @bitwarden/team-key-management-dev +apps/desktop/src/services/biometric-message-handler.service.ts @bitwarden/team-key-management-dev ## Locales ## apps/browser/src/_locales/en/messages.json diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 62fc93ae0b86..040102d03951 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -106,9 +106,10 @@ import { ElectronRendererStorageService } from "../../platform/services/electron import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging"; import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme"; +import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service"; import { DesktopLockComponentService } from "../../services/desktop-lock-component.service"; +import { DuckDuckGoMessageHandlerService } from "../../services/duckduckgo-message-handler.service"; import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service"; -import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; import { SearchBarService } from "../layout/search/search-bar.service"; @@ -134,6 +135,7 @@ const safeProviders: SafeProvider[] = [ deps: [], }), safeProvider(NativeMessagingService), + safeProvider(BiometricMessageHandlerService), safeProvider(SearchBarService), safeProvider(DialogService), safeProvider({ @@ -257,7 +259,7 @@ const safeProviders: SafeProvider[] = [ ], }), safeProvider({ - provide: NativeMessageHandlerService, + provide: DuckDuckGoMessageHandlerService, deps: [ StateServiceAbstraction, EncryptService, diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts new file mode 100644 index 000000000000..8e5a52aba838 --- /dev/null +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -0,0 +1,238 @@ +import { Injectable, NgZone } from "@angular/core"; +import { firstValueFrom, map } 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 { 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 { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; +import { LegacyMessage } from "../models/native-messaging/legacy-message"; +import { LegacyMessageWrapper } from "../models/native-messaging/legacy-message-wrapper"; +import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; + +const MessageValidTimeout = 10 * 1000; +const HashAlgorithmForAsymmetricEncryption = "sha1"; + +@Injectable() +export class BiometricMessageHandlerService { + constructor( + private cryptoFunctionService: CryptoFunctionService, + private keyService: KeyService, + private encryptService: EncryptService, + private logService: LogService, + private messagingService: MessagingService, + private desktopSettingService: DesktopSettingsService, + private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, + private dialogService: DialogService, + private accountService: AccountService, + private authService: AuthService, + private ngZone: NgZone, + ) {} + + async handleMessage(msg: LegacyMessageWrapper) { + const { appId, message: rawMessage } = msg as LegacyMessageWrapper; + + // Request to setup secure encryption + if ("command" in rawMessage && rawMessage.command === "setupEncryption") { + const remotePublicKey = Utils.fromB64ToArray(rawMessage.publicKey); + + // Validate the UserId to ensure we are logged into the same account. + const accounts = await firstValueFrom(this.accountService.accounts$); + const userIds = Object.keys(accounts); + if (!userIds.includes(rawMessage.userId)) { + ipc.platform.nativeMessaging.sendMessage({ + command: "wrongUserId", + appId: appId, + }); + return; + } + + if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) { + ipc.platform.nativeMessaging.sendMessage({ + command: "verifyFingerprint", + appId: appId, + }); + + const fingerprint = await this.keyService.getFingerprint( + rawMessage.userId, + remotePublicKey, + ); + + this.messagingService.send("setFocus"); + + const dialogRef = this.ngZone.run(() => + BrowserSyncVerificationDialogComponent.open(this.dialogService, { fingerprint }), + ); + + const browserSyncVerified = await firstValueFrom(dialogRef.closed); + + if (browserSyncVerified !== true) { + return; + } + } + + await this.secureCommunication(remotePublicKey, appId); + return; + } + + if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) { + ipc.platform.nativeMessaging.sendMessage({ + command: "invalidateEncryption", + appId: appId, + }); + return; + } + + const message: LegacyMessage = JSON.parse( + await this.encryptService.decryptToUtf8( + rawMessage as EncString, + SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), + ), + ); + + // Shared secret is invalidated, force re-authentication + if (message == null) { + ipc.platform.nativeMessaging.sendMessage({ + command: "invalidateEncryption", + appId: appId, + }); + return; + } + + if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) { + this.logService.error("NativeMessage is to old, ignoring."); + return; + } + + switch (message.command) { + case "biometricUnlock": { + const isTemporarilyDisabled = + (await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) && + !(await this.biometricsService.supportsBiometric()); + if (isTemporarilyDisabled) { + return this.send({ command: "biometricUnlock", response: "not available" }, appId); + } + + if (!(await this.biometricsService.supportsBiometric())) { + return this.send({ command: "biometricUnlock", response: "not supported" }, appId); + } + + const userId = + (message.userId as UserId) ?? + (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); + + if (userId == null) { + return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId); + } + + const biometricUnlockPromise = + message.userId == null + ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) + : this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId); + if (!(await biometricUnlockPromise)) { + await this.send({ command: "biometricUnlock", response: "not enabled" }, appId); + + return this.ngZone.run(() => + this.dialogService.openSimpleDialog({ + type: "warning", + title: { key: "biometricsNotEnabledTitle" }, + content: { key: "biometricsNotEnabledDesc" }, + cancelButtonText: null, + acceptButtonText: { key: "cancel" }, + }), + ); + } + + try { + const userKey = await this.keyService.getUserKeyFromStorage( + KeySuffixOptions.Biometric, + message.userId, + ); + + if (userKey != null) { + await this.send( + { + command: "biometricUnlock", + response: "unlocked", + userKeyB64: userKey.keyB64, + }, + appId, + ); + + const currentlyActiveAccountId = ( + await firstValueFrom(this.accountService.activeAccount$) + ).id; + const isCurrentlyActiveAccountUnlocked = + (await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked; + + // prevent proc reloading an active account, when it is the same as the browser + if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) { + await ipc.platform.reloadProcess(); + } + } else { + await this.send({ command: "biometricUnlock", response: "canceled" }, appId); + } + } 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; + } + } + + private async send(message: any, appId: string) { + message.timestamp = Date.now(); + + const encrypted = await this.encryptService.encrypt( + JSON.stringify(message), + SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), + ); + + ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted }); + } + + private async secureCommunication(remotePublicKey: Uint8Array, appId: string) { + const secret = await this.cryptoFunctionService.randomBytes(64); + await ipc.platform.ephemeralStore.setEphemeralValue( + appId, + new SymmetricCryptoKey(secret).keyB64, + ); + + const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt( + secret, + remotePublicKey, + HashAlgorithmForAsymmetricEncryption, + ); + ipc.platform.nativeMessaging.sendMessage({ + appId: appId, + command: "setupEncryption", + sharedSecret: Utils.fromBufferToB64(encryptedSecret), + }); + } +} diff --git a/apps/desktop/src/services/native-message-handler.service.ts b/apps/desktop/src/services/duckduckgo-message-handler.service.ts similarity index 95% rename from apps/desktop/src/services/native-message-handler.service.ts rename to apps/desktop/src/services/duckduckgo-message-handler.service.ts index a99effce9eb7..db42f7b4deeb 100644 --- a/apps/desktop/src/services/native-message-handler.service.ts +++ b/apps/desktop/src/services/duckduckgo-message-handler.service.ts @@ -26,8 +26,8 @@ const HashAlgorithmForAsymmetricEncryption = "sha1"; // This service handles messages using the protocol created for the DuckDuckGo integration. @Injectable() -export class NativeMessageHandlerService { - private ddgSharedSecret: SymmetricCryptoKey; +export class DuckDuckGoMessageHandlerService { + private duckduckgoSharedSecret: SymmetricCryptoKey; constructor( private stateService: StateService, @@ -109,7 +109,7 @@ export class NativeMessageHandlerService { } const secret = await this.cryptoFunctionService.randomBytes(64); - this.ddgSharedSecret = new SymmetricCryptoKey(secret); + this.duckduckgoSharedSecret = new SymmetricCryptoKey(secret); const sharedKeyB64 = new SymmetricCryptoKey(secret).keyB64; await this.stateService.setDuckDuckGoSharedKey(sharedKeyB64); @@ -166,7 +166,7 @@ export class NativeMessageHandlerService { } private async decryptPayload(message: EncryptedMessage): Promise { - if (!this.ddgSharedSecret) { + if (!this.duckduckgoSharedSecret) { const storedKey = await this.stateService.getDuckDuckGoSharedKey(); if (storedKey == null) { this.sendResponse({ @@ -178,13 +178,13 @@ export class NativeMessageHandlerService { }); return; } - this.ddgSharedSecret = SymmetricCryptoKey.fromJSON({ keyB64: storedKey }); + this.duckduckgoSharedSecret = SymmetricCryptoKey.fromJSON({ keyB64: storedKey }); } try { let decryptedResult = await this.encryptService.decryptToUtf8( message.encryptedCommand as EncString, - this.ddgSharedSecret, + this.duckduckgoSharedSecret, "ddg-shared-key", ); @@ -207,7 +207,7 @@ export class NativeMessageHandlerService { originalMessage: EncryptedMessage, response: DecryptedCommandData, ) { - if (!this.ddgSharedSecret) { + if (!this.duckduckgoSharedSecret) { this.sendResponse({ messageId: originalMessage.messageId, version: NativeMessagingVersion.Latest, @@ -219,7 +219,7 @@ export class NativeMessageHandlerService { return; } - const encryptedPayload = await this.encryptPayload(response, this.ddgSharedSecret); + const encryptedPayload = await this.encryptPayload(response, this.duckduckgoSharedSecret); this.sendResponse({ messageId: originalMessage.messageId, diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index 2312bfb2f6b0..11dc35f95e8c 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -1,48 +1,16 @@ -import { Injectable, NgZone } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { Injectable } from "@angular/core"; -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 { 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 { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management"; - -import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; -import { LegacyMessage } from "../models/native-messaging/legacy-message"; import { LegacyMessageWrapper } from "../models/native-messaging/legacy-message-wrapper"; import { Message } from "../models/native-messaging/message"; -import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; - -import { NativeMessageHandlerService } from "./native-message-handler.service"; -const MessageValidTimeout = 10 * 1000; -const HashAlgorithmForAsymmetricEncryption = "sha1"; +import { BiometricMessageHandlerService } from "./biometric-message-handler.service"; +import { DuckDuckGoMessageHandlerService } from "./duckduckgo-message-handler.service"; @Injectable() export class NativeMessagingService { constructor( - private cryptoFunctionService: CryptoFunctionService, - private keyService: KeyService, - private encryptService: EncryptService, - private logService: LogService, - private messagingService: MessagingService, - private desktopSettingService: DesktopSettingsService, - private biometricStateService: BiometricStateService, - private biometricsService: BiometricsService, - private nativeMessageHandler: NativeMessageHandlerService, - private dialogService: DialogService, - private accountService: AccountService, - private authService: AuthService, - private ngZone: NgZone, + private duckduckgoMessageHandler: DuckDuckGoMessageHandlerService, + private biometricMessageHandler: BiometricMessageHandlerService, ) {} init() { @@ -53,202 +21,11 @@ export class NativeMessagingService { const outerMessage = msg as Message; if (outerMessage.version) { // If there is a version, it is a using the protocol created for the DuckDuckGo integration - await this.nativeMessageHandler.handleMessage(outerMessage); - return; - } - - const { appId, message: rawMessage } = msg as LegacyMessageWrapper; - - // Request to setup secure encryption - if ("command" in rawMessage && rawMessage.command === "setupEncryption") { - const remotePublicKey = Utils.fromB64ToArray(rawMessage.publicKey); - - // Validate the UserId to ensure we are logged into the same account. - const accounts = await firstValueFrom(this.accountService.accounts$); - const userIds = Object.keys(accounts); - if (!userIds.includes(rawMessage.userId)) { - ipc.platform.nativeMessaging.sendMessage({ - command: "wrongUserId", - appId: appId, - }); - return; - } - - if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) { - ipc.platform.nativeMessaging.sendMessage({ - command: "verifyFingerprint", - appId: appId, - }); - - const fingerprint = await this.keyService.getFingerprint( - rawMessage.userId, - remotePublicKey, - ); - - this.messagingService.send("setFocus"); - - const dialogRef = this.ngZone.run(() => - BrowserSyncVerificationDialogComponent.open(this.dialogService, { fingerprint }), - ); - - const browserSyncVerified = await firstValueFrom(dialogRef.closed); - - if (browserSyncVerified !== true) { - return; - } - } - - await this.secureCommunication(remotePublicKey, appId); - return; - } - - if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) { - ipc.platform.nativeMessaging.sendMessage({ - command: "invalidateEncryption", - appId: appId, - }); - return; - } - - const message: LegacyMessage = JSON.parse( - await this.encryptService.decryptToUtf8( - rawMessage as EncString, - SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), - `native-messaging-session-${appId}`, - ), - ); - - // Shared secret is invalidated, force re-authentication - if (message == null) { - ipc.platform.nativeMessaging.sendMessage({ - command: "invalidateEncryption", - appId: appId, - }); + await this.duckduckgoMessageHandler.handleMessage(outerMessage); return; - } - - if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) { - this.logService.error("NativeMessage is to old, ignoring."); + } else { + await this.biometricMessageHandler.handleMessage(msg as LegacyMessageWrapper); return; } - - switch (message.command) { - case "biometricUnlock": { - const isTemporarilyDisabled = - (await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) && - !(await this.biometricsService.supportsBiometric()); - if (isTemporarilyDisabled) { - return this.send({ command: "biometricUnlock", response: "not available" }, appId); - } - - if (!(await this.biometricsService.supportsBiometric())) { - return this.send({ command: "biometricUnlock", response: "not supported" }, appId); - } - - const userId = - (message.userId as UserId) ?? - (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); - - if (userId == null) { - return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId); - } - - const biometricUnlockPromise = - message.userId == null - ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) - : this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId); - if (!(await biometricUnlockPromise)) { - await this.send({ command: "biometricUnlock", response: "not enabled" }, appId); - - return this.ngZone.run(() => - this.dialogService.openSimpleDialog({ - type: "warning", - title: { key: "biometricsNotEnabledTitle" }, - content: { key: "biometricsNotEnabledDesc" }, - cancelButtonText: null, - acceptButtonText: { key: "cancel" }, - }), - ); - } - - try { - const userKey = await this.keyService.getUserKeyFromStorage( - KeySuffixOptions.Biometric, - message.userId, - ); - - if (userKey != null) { - await this.send( - { - command: "biometricUnlock", - response: "unlocked", - userKeyB64: userKey.keyB64, - }, - appId, - ); - - const currentlyActiveAccountId = ( - await firstValueFrom(this.accountService.activeAccount$) - ).id; - const isCurrentlyActiveAccountUnlocked = - (await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked; - - // prevent proc reloading an active account, when it is the same as the browser - if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) { - await ipc.platform.reloadProcess(); - } - } else { - await this.send({ command: "biometricUnlock", response: "canceled" }, appId); - } - } 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; - } - } - - private async send(message: any, appId: string) { - message.timestamp = Date.now(); - - const encrypted = await this.encryptService.encrypt( - JSON.stringify(message), - SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), - ); - - ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted }); - } - - private async secureCommunication(remotePublicKey: Uint8Array, appId: string) { - const secret = await this.cryptoFunctionService.randomBytes(64); - await ipc.platform.ephemeralStore.setEphemeralValue( - appId, - new SymmetricCryptoKey(secret).keyB64, - ); - - const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt( - secret, - remotePublicKey, - HashAlgorithmForAsymmetricEncryption, - ); - ipc.platform.nativeMessaging.sendMessage({ - appId: appId, - command: "setupEncryption", - sharedSecret: Utils.fromBufferToB64(encryptedSecret), - }); } }