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 { 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, 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"; 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; let i18nService: 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(); 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, keyService, encryptService, logService, messagingService, desktopSettingsService, biometricStateService, biometricsService, dialogService, 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 [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(); } }, ); }); });