From ef20ca83b60ab5561fb11977bb632a7923d1130e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 21 Jan 2025 21:26:34 +0100 Subject: [PATCH] [PM-17121/17204] Fix fingerprint dialogs and disabled active biometric lock component (#12928) * Fix biometrics unlock window being empty * Add trust on sensitive action * Add dialog for outdated desktop app and fix spelling * Use updated fingerprint method * Refactor connected app trust * Move connected apps to ephemeral value store and show error on outdated browser * Move trust logic to only occur when fingerprint setting is enabled * Add more tests * Simplify code * Update ephemeral value list call to "listEphemeralValueKeys" * Fix trust being ignored --- apps/browser/src/_locales/en/messages.json | 6 + .../background/nativeMessaging.background.ts | 38 +-- apps/browser/src/popup/app.component.ts | 9 +- ...ktop-sync-verification-dialog.component.ts | 32 +- apps/desktop/src/locales/en/messages.json | 6 + apps/desktop/src/platform/preload.ts | 1 + .../ephemeral-value-storage.main.service.ts | 3 + .../biometric-message-handler.service.spec.ts | 283 +++++++++++++++++- .../biometric-message-handler.service.ts | 183 +++++++++-- .../lock/components/lock.component.html | 6 +- 10 files changed, 499 insertions(+), 68 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ecb47843df2..6b1764289f8 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4868,5 +4868,11 @@ }, "extraWide": { "message": "Extra wide" + }, + "updateDesktopAppOrDisableFingerprintDialogTitle": { + "message": "Please update your desktop application" + }, + "updateDesktopAppOrDisableFingerprintDialogMessage": { + "message": "To use biometric unlock, please update your desktop application, or disable fingerprint unlock in the desktop settings." } } diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index b08f1c8b566..21c8f351e8d 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -162,10 +162,6 @@ export class NativeMessagingBackground { HashAlgorithmForEncryption, ); - if (this.validatingFingerprint) { - this.validatingFingerprint = false; - await this.biometricStateService.setFingerprintValidated(true); - } this.sharedSecret = new SymmetricCryptoKey(decrypted); this.logService.info("[Native Messaging IPC] Secure channel established"); @@ -200,15 +196,24 @@ export class NativeMessagingBackground { } 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 - this.showFingerprintDialog(); - } + this.logService.info("[Native Messaging IPC] Legacy app is requesting fingerprint"); + this.messagingService.send("showUpdateDesktopAppOrDisableFingerprintDialog", {}); + break; + } + case "verifyDesktopIPCFingerprint": { + this.logService.info( + "[Native Messaging IPC] Desktop app requested trust verification by fingerprint.", + ); + await this.showFingerprintDialog(); + break; + } + case "verifiedDesktopIPCFingerprint": { + await this.biometricStateService.setFingerprintValidated(true); + this.messagingService.send("hideNativeMessagingFingerprintDialog", {}); + break; + } + case "rejectedDesktopIPCFingerprint": { + this.messagingService.send("hideNativeMessagingFingerprintDialog", {}); break; } case "wrongUserId": @@ -428,12 +433,9 @@ export class NativeMessagingBackground { } private async showFingerprintDialog() { - const fingerprint = await this.keyService.getFingerprint( - (await firstValueFrom(this.accountService.activeAccount$))?.id, - this.publicKey, - ); + const fingerprint = await this.keyService.getFingerprint(this.appId, this.publicKey); - this.messagingService.send("showNativeMessagingFinterprintDialog", { + this.messagingService.send("showNativeMessagingFingerprintDialog", { fingerprint: fingerprint, }); } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 9d4835889b9..8147524d2f3 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -128,11 +128,18 @@ export class AppComponent implements OnInit, OnDestroy { // 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.showDialog(msg); - } else if (msg.command === "showNativeMessagingFinterprintDialog") { + } else if (msg.command === "showNativeMessagingFingerprintDialog") { // TODO: Should be refactored to live in another service. // 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.showNativeMessagingFingerprintDialog(msg); + } else if (msg.command === "showUpdateDesktopAppOrDisableFingerprintDialog") { + // TODO: Should be refactored to live in another service. + await this.showDialog({ + title: this.i18nService.t("updateDesktopAppOrDisableFingerprintDialogTitle"), + content: this.i18nService.t("updateDesktopAppOrDisableFingerprintDialogMessage"), + type: "warning", + }); } else if (msg.command === "showToast") { this.toastService._showToast(msg); } else if (msg.command === "reloadProcess") { diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts index c860ef1e342..2a5a56e7dfc 100644 --- a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts @@ -1,7 +1,9 @@ -import { DIALOG_DATA } from "@angular/cdk/dialog"; -import { Component, Inject } from "@angular/core"; +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { filter, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; export type DesktopSyncVerificationDialogParams = { @@ -13,8 +15,30 @@ export type DesktopSyncVerificationDialogParams = { standalone: true, imports: [JslibModule, ButtonModule, DialogModule], }) -export class DesktopSyncVerificationDialogComponent { - constructor(@Inject(DIALOG_DATA) protected params: DesktopSyncVerificationDialogParams) {} +export class DesktopSyncVerificationDialogComponent implements OnDestroy, OnInit { + private destroy$ = new Subject(); + + constructor( + @Inject(DIALOG_DATA) protected params: DesktopSyncVerificationDialogParams, + private dialogRef: DialogRef, + private messageListener: MessageListener, + ) {} + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.messageListener.allMessages$ + .pipe( + filter((m) => m.command === "hideNativeMessagingFingerprintDialog"), + takeUntil(this.destroy$), + ) + .subscribe(() => { + this.dialogRef.close(); + }); + } static open(dialogService: DialogService, data: DesktopSyncVerificationDialogParams) { return dialogService.open(DesktopSyncVerificationDialogComponent, { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index bca12f16a7d..8ae2cdc3ef6 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3483,5 +3483,11 @@ }, "upgradeOrganizationDesc": { "message": "This feature is not available for free organizations. Switch to a paid plan to unlock more features." + }, + "updateBrowserOrDisableFingerprintDialogTitle": { + "message": "Extension update required" + }, + "updateBrowserOrDisableFingerprintDialogMessage": { + "message": "The browser extension you are using is out of date. Please update it or disable browser integration fingerprint validation in the desktop app settings." } } diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index a37274677ff..24360ecbbb5 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -123,6 +123,7 @@ const ephemeralStore = { getEphemeralValue: (key: string): Promise => ipcRenderer.invoke("getEphemeralValue", key), removeEphemeralValue: (key: string): Promise => ipcRenderer.invoke("deleteEphemeralValue", key), + listEphemeralValueKeys: (): Promise => ipcRenderer.invoke("listEphemeralValueKeys"), }; const localhostCallbackService = { diff --git a/apps/desktop/src/platform/services/ephemeral-value-storage.main.service.ts b/apps/desktop/src/platform/services/ephemeral-value-storage.main.service.ts index b59b48be1e1..0dd6ef56ae4 100644 --- a/apps/desktop/src/platform/services/ephemeral-value-storage.main.service.ts +++ b/apps/desktop/src/platform/services/ephemeral-value-storage.main.service.ts @@ -17,5 +17,8 @@ export class EphemeralValueStorageService { ipcMain.handle("deleteEphemeralValue", async (event, key: string) => { this.ephemeralValues.delete(key); }); + ipcMain.handle("listEphemeralValueKeys", async (event) => { + return Array.from(this.ephemeralValues.keys()); + }); } } diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index 13b668f6b83..a7f0f555ca2 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -9,21 +9,22 @@ 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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService } from "@bitwarden/common/spec"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DialogService } from "@bitwarden/components"; -import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management"; +import { DialogService, I18nMockService } from "@bitwarden/components"; +import { + KeyService, + BiometricsService, + BiometricStateService, + BiometricsCommands, +} 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 = { @@ -54,6 +55,7 @@ describe("BiometricMessageHandlerService", () => { let accountService: AccountService; let authService: MockProxy; let ngZone: MockProxy; + let i18nService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -69,6 +71,24 @@ describe("BiometricMessageHandlerService", () => { accountService = new FakeAccountService(accounts); authService = mock(); ngZone = mock(); + i18nService = mock(); + + (global as any).ipc = { + platform: { + ephemeralStore: { + listEphemeralValueKeys: jest.fn(), + getEphemeralValue: jest.fn(), + removeEphemeralValue: jest.fn(), + setEphemeralValue: jest.fn(), + }, + nativeMessaging: { + sendMessage: jest.fn(), + }, + reloadProcess: jest.fn(), + }, + }; + cryptoFunctionService.rsaEncrypt.mockResolvedValue(Utils.fromUtf8ToArray("encrypted")); + cryptoFunctionService.randomBytes.mockResolvedValue(new Uint8Array(64) as CsprngArray); service = new BiometricMessageHandlerService( cryptoFunctionService, @@ -83,9 +103,256 @@ describe("BiometricMessageHandlerService", () => { accountService, authService, ngZone, + i18nService, ); }); + describe("setup encryption", () => { + it("should reject when user is not in app", async () => { + await service.handleMessage({ + appId: "appId", + message: { + command: "setupEncryption", + messageId: 0, + userId: "unknownUser" as UserId, + }, + }); + expect((global as any).ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + appId: "appId", + command: "wrongUserId", + }); + }); + + it("should setup secure communication", async () => { + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: null, + trusted: false, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: "setupEncryption", + messageId: 0, + userId: SomeUser, + publicKey: Utils.fromUtf8ToB64("publicKey"), + }, + }); + expect((global as any).ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + appId: "appId", + command: "setupEncryption", + messageId: -1, + sharedSecret: Utils.fromUtf8ToB64("encrypted"), + }); + expect((global as any).ipc.platform.ephemeralStore.setEphemeralValue).toHaveBeenCalledWith( + "connectedApp_appId", + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: false, + }), + ); + }); + + it("should invalidate encryption if connection is not secured", async () => { + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: null, + trusted: false, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: "biometricUnlock", + messageId: 0, + userId: SomeUser, + }, + }); + expect((global as any).ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + appId: "appId", + command: "invalidateEncryption", + }); + }); + + it("should show update dialog when legacy unlock is requested with fingerprint active", async () => { + desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(true); + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: false, + }), + ); + encryptService.decryptToUtf8.mockResolvedValue( + JSON.stringify({ + command: "biometricUnlock", + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: "biometricUnlock", + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }, + }); + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + }); + + it("should send verify fingerprint when fingerprinting is required on modern unlock, and dialog is accepted, and set to trusted", async () => { + desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(true); + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: false, + }), + ); + ngZone.run.mockReturnValue({ + closed: of(true), + }); + encryptService.decryptToUtf8.mockResolvedValue( + JSON.stringify({ + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }, + }); + + expect(ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + command: "verifyDesktopIPCFingerprint", + appId: "appId", + }); + expect(ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + command: "verifiedDesktopIPCFingerprint", + appId: "appId", + }); + expect(ipc.platform.ephemeralStore.setEphemeralValue).toHaveBeenCalledWith( + "connectedApp_appId", + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: true, + }), + ); + }); + + it("should send reject fingerprint when fingerprinting is required on modern unlock, and dialog is rejected, and it should not set to trusted", async () => { + desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(true); + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: false, + }), + ); + ngZone.run.mockReturnValue({ + closed: of(false), + }); + encryptService.decryptToUtf8.mockResolvedValue( + JSON.stringify({ + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }, + }); + expect(ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + command: "verifyDesktopIPCFingerprint", + appId: "appId", + }); + expect(ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ + command: "rejectedDesktopIPCFingerprint", + appId: "appId", + }); + expect(ipc.platform.ephemeralStore.setEphemeralValue).not.toHaveBeenCalledWith( + "connectedApp_appId", + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: true, + }), + ); + }); + + it("should not attempt to verify when the connected app is already trusted", async () => { + desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(true); + (global as any).ipc.platform.ephemeralStore.listEphemeralValueKeys.mockResolvedValue([ + "connectedApp_appId", + ]); + (global as any).ipc.platform.ephemeralStore.getEphemeralValue.mockResolvedValue( + JSON.stringify({ + publicKey: Utils.fromUtf8ToB64("publicKey"), + sessionSecret: Utils.fromBufferToB64(new Uint8Array(64)), + trusted: true, + }), + ); + encryptService.decryptToUtf8.mockResolvedValue( + JSON.stringify({ + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }), + ); + await service.handleMessage({ + appId: "appId", + message: { + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId: 0, + timestamp: Date.now(), + userId: SomeUser, + }, + }); + expect(ipc.platform.nativeMessaging.sendMessage).not.toHaveBeenCalledWith({ + command: "verifyDesktopIPCFingerprint", + appId: "appId", + }); + }); + }); + describe("process reload", () => { const testCases = [ // don't reload when the active user is the requested one and unlocked diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 74f80785fca..0482434708e 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -1,13 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable, NgZone } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { combineLatest, concatMap, 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 { 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 { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -31,6 +32,50 @@ import { DesktopSettingsService } from "../platform/services/desktop-settings.se const MessageValidTimeout = 10 * 1000; const HashAlgorithmForAsymmetricEncryption = "sha1"; +type ConnectedApp = { + publicKey: string; + sessionSecret: string; + trusted: boolean; +}; + +const ConnectedAppPrefix = "connectedApp_"; + +class ConnectedApps { + async get(appId: string): Promise { + if (!(await this.has(appId))) { + return null; + } + + return JSON.parse( + await ipc.platform.ephemeralStore.getEphemeralValue(`${ConnectedAppPrefix}${appId}`), + ); + } + + async list(): Promise { + return (await ipc.platform.ephemeralStore.listEphemeralValueKeys()) + .filter((key) => key.startsWith(ConnectedAppPrefix)) + .map((key) => key.replace(ConnectedAppPrefix, "")); + } + + async set(appId: string, value: ConnectedApp) { + await ipc.platform.ephemeralStore.setEphemeralValue( + `${ConnectedAppPrefix}${appId}`, + JSON.stringify(value), + ); + } + + async has(appId: string) { + return (await this.list()).find((id) => id === appId) != null; + } + + async clear() { + const connected = await this.list(); + for (const appId of connected) { + await ipc.platform.ephemeralStore.removeEphemeralValue(`${ConnectedAppPrefix}${appId}`); + } + } +} + @Injectable() export class BiometricMessageHandlerService { constructor( @@ -46,7 +91,21 @@ export class BiometricMessageHandlerService { private accountService: AccountService, private authService: AuthService, private ngZone: NgZone, - ) {} + private i18nService: I18nService, + ) { + combineLatest([ + this.desktopSettingService.browserIntegrationFingerprintEnabled$, + this.desktopSettingService.browserIntegrationEnabled$, + ]) + .pipe( + concatMap(async () => { + await this.connectedApps.clear(); + }), + ) + .subscribe(); + } + + private connectedApps: ConnectedApps = new ConnectedApps(); async handleMessage(msg: LegacyMessageWrapper) { const { appId, message: rawMessage } = msg as LegacyMessageWrapper; @@ -69,39 +128,24 @@ export class BiometricMessageHandlerService { return; } - if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) { - this.logService.info("[Native Messaging IPC] Requesting fingerprint verification."); - ipc.platform.nativeMessaging.sendMessage({ - command: "verifyFingerprint", - appId: appId, - }); - - const fingerprint = await this.keyService.getFingerprint( - rawMessage.userId, - remotePublicKey, + if (await this.connectedApps.has(appId)) { + this.logService.info( + "[Native Messaging IPC] Public key for app id changed. Invalidating trust", ); - - this.messagingService.send("setFocus"); - - const dialogRef = this.ngZone.run(() => - BrowserSyncVerificationDialogComponent.open(this.dialogService, { fingerprint }), - ); - - const browserSyncVerified = await firstValueFrom(dialogRef.closed); - - if (browserSyncVerified !== true) { - this.logService.info("[Native Messaging IPC] Fingerprint verification failed."); - return; - } } + await this.connectedApps.set(appId, { + publicKey: Utils.fromBufferToB64(remotePublicKey), + sessionSecret: null, + trusted: false, + }); await this.secureCommunication(remotePublicKey, appId); return; } - if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) { + if ((await this.connectedApps.get(appId))?.sessionSecret == null) { this.logService.info( - "[Native Messaging IPC] Epheremal secret for secure channel is missing. Invalidating encryption...", + "[Native Messaging IPC] Session secret for secure channel is missing. Invalidating encryption...", ); ipc.platform.nativeMessaging.sendMessage({ command: "invalidateEncryption", @@ -113,7 +157,7 @@ export class BiometricMessageHandlerService { const message: LegacyMessage = JSON.parse( await this.encryptService.decryptToUtf8( rawMessage as EncString, - SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), + SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret), ), ); @@ -202,6 +246,18 @@ export class BiometricMessageHandlerService { } // TODO: legacy, remove after 2025.3 case BiometricsCommands.Unlock: { + if ( + await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$) + ) { + await this.send({ command: "biometricUnlock", response: "not available" }, appId); + await this.dialogService.openSimpleDialog({ + title: this.i18nService.t("updateBrowserOrDisableFingerprintDialogTitle"), + content: this.i18nService.t("updateBrowserOrDisableFingerprintDialogMessage"), + type: "warning", + }); + return; + } + const isTemporarilyDisabled = (await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) && !((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available); @@ -283,7 +339,7 @@ export class BiometricMessageHandlerService { const encrypted = await this.encryptService.encrypt( JSON.stringify(message), - SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)), + SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret), ); ipc.platform.nativeMessaging.sendMessage({ @@ -295,10 +351,9 @@ export class BiometricMessageHandlerService { 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 connectedApp = await this.connectedApps.get(appId); + connectedApp.sessionSecret = new SymmetricCryptoKey(secret).keyB64; + await this.connectedApps.set(appId, connectedApp); this.logService.info("[Native Messaging IPC] Setting up secure channel"); const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt( @@ -320,6 +375,18 @@ export class BiometricMessageHandlerService { appId: string, ) { const messageUserId = message.userId as UserId; + if (!(await this.validateFingerprint(appId))) { + await this.send( + { + command: BiometricsCommands.UnlockWithBiometricsForUser, + messageId, + response: false, + }, + appId, + ); + return; + } + try { const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId); if (userKey != null) { @@ -369,4 +436,54 @@ export class BiometricMessageHandlerService { } } } + + async validateFingerprint(appId: string): Promise { + if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) { + const appToValidate = await this.connectedApps.get(appId); + if (appToValidate == null) { + return false; + } + + if (appToValidate.trusted) { + return true; + } + + ipc.platform.nativeMessaging.sendMessage({ + command: "verifyDesktopIPCFingerprint", + appId: appId, + }); + + const fingerprint = await this.keyService.getFingerprint( + appId, + Utils.fromB64ToArray(appToValidate.publicKey), + ); + + this.messagingService.send("setFocus"); + + const dialogRef = this.ngZone.run(() => + BrowserSyncVerificationDialogComponent.open(this.dialogService, { fingerprint }), + ); + + const browserSyncVerified = await firstValueFrom(dialogRef.closed); + if (browserSyncVerified !== true) { + this.logService.info("[Native Messaging IPC] Fingerprint verification failed."); + ipc.platform.nativeMessaging.sendMessage({ + command: "rejectedDesktopIPCFingerprint", + appId: appId, + }); + return false; + } else { + this.logService.info("[Native Messaging IPC] Fingerprint verified."); + ipc.platform.nativeMessaging.sendMessage({ + command: "verifiedDesktopIPCFingerprint", + appId: appId, + }); + } + + appToValidate.trusted = true; + await this.connectedApps.set(appId, appToValidate); + } + + return true; + } } 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 7d9ed6124f6..437e29447e2 100644 --- a/libs/key-management/src/angular/lock/components/lock.component.html +++ b/libs/key-management/src/angular/lock/components/lock.component.html @@ -6,15 +6,13 @@ - +