mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[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
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: DesktopSyncVerificationDialogParams,
|
||||
private dialogRef: DialogRef<DesktopSyncVerificationDialogComponent>,
|
||||
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, {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ const ephemeralStore = {
|
||||
getEphemeralValue: (key: string): Promise<string> => ipcRenderer.invoke("getEphemeralValue", key),
|
||||
removeEphemeralValue: (key: string): Promise<void> =>
|
||||
ipcRenderer.invoke("deleteEphemeralValue", key),
|
||||
listEphemeralValueKeys: (): Promise<string[]> => ipcRenderer.invoke("listEphemeralValueKeys"),
|
||||
};
|
||||
|
||||
const localhostCallbackService = {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuthService>;
|
||||
let ngZone: MockProxy<NgZone>;
|
||||
let i18nService: MockProxy<I18nMockService>;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
@@ -69,6 +71,24 @@ describe("BiometricMessageHandlerService", () => {
|
||||
accountService = new FakeAccountService(accounts);
|
||||
authService = mock<AuthService>();
|
||||
ngZone = mock<NgZone>();
|
||||
i18nService = mock<I18nMockService>();
|
||||
|
||||
(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
|
||||
|
||||
@@ -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<ConnectedApp> {
|
||||
if (!(await this.has(appId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(
|
||||
await ipc.platform.ephemeralStore.getEphemeralValue(`${ConnectedAppPrefix}${appId}`),
|
||||
);
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user