mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +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": {
|
"extraWide": {
|
||||||
"message": "Extra wide"
|
"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,
|
HashAlgorithmForEncryption,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.validatingFingerprint) {
|
|
||||||
this.validatingFingerprint = false;
|
|
||||||
await this.biometricStateService.setFingerprintValidated(true);
|
|
||||||
}
|
|
||||||
this.sharedSecret = new SymmetricCryptoKey(decrypted);
|
this.sharedSecret = new SymmetricCryptoKey(decrypted);
|
||||||
this.logService.info("[Native Messaging IPC] Secure channel established");
|
this.logService.info("[Native Messaging IPC] Secure channel established");
|
||||||
|
|
||||||
@@ -200,15 +196,24 @@ export class NativeMessagingBackground {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case "verifyFingerprint": {
|
case "verifyFingerprint": {
|
||||||
if (this.sharedSecret == null) {
|
this.logService.info("[Native Messaging IPC] Legacy app is requesting fingerprint");
|
||||||
this.logService.info(
|
this.messagingService.send("showUpdateDesktopAppOrDisableFingerprintDialog", {});
|
||||||
"[Native Messaging IPC] Desktop app requested trust verification by fingerprint.",
|
break;
|
||||||
);
|
}
|
||||||
this.validatingFingerprint = true;
|
case "verifyDesktopIPCFingerprint": {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
this.logService.info(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
"[Native Messaging IPC] Desktop app requested trust verification by fingerprint.",
|
||||||
this.showFingerprintDialog();
|
);
|
||||||
}
|
await this.showFingerprintDialog();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "verifiedDesktopIPCFingerprint": {
|
||||||
|
await this.biometricStateService.setFingerprintValidated(true);
|
||||||
|
this.messagingService.send("hideNativeMessagingFingerprintDialog", {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "rejectedDesktopIPCFingerprint": {
|
||||||
|
this.messagingService.send("hideNativeMessagingFingerprintDialog", {});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "wrongUserId":
|
case "wrongUserId":
|
||||||
@@ -428,12 +433,9 @@ export class NativeMessagingBackground {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async showFingerprintDialog() {
|
private async showFingerprintDialog() {
|
||||||
const fingerprint = await this.keyService.getFingerprint(
|
const fingerprint = await this.keyService.getFingerprint(this.appId, this.publicKey);
|
||||||
(await firstValueFrom(this.accountService.activeAccount$))?.id,
|
|
||||||
this.publicKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.messagingService.send("showNativeMessagingFinterprintDialog", {
|
this.messagingService.send("showNativeMessagingFingerprintDialog", {
|
||||||
fingerprint: fingerprint,
|
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.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.showDialog(msg);
|
this.showDialog(msg);
|
||||||
} else if (msg.command === "showNativeMessagingFinterprintDialog") {
|
} else if (msg.command === "showNativeMessagingFingerprintDialog") {
|
||||||
// TODO: Should be refactored to live in another service.
|
// 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.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.showNativeMessagingFingerprintDialog(msg);
|
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") {
|
} else if (msg.command === "showToast") {
|
||||||
this.toastService._showToast(msg);
|
this.toastService._showToast(msg);
|
||||||
} else if (msg.command === "reloadProcess") {
|
} else if (msg.command === "reloadProcess") {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||||
import { Component, Inject } from "@angular/core";
|
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||||
|
import { filter, Subject, takeUntil } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||||
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
export type DesktopSyncVerificationDialogParams = {
|
export type DesktopSyncVerificationDialogParams = {
|
||||||
@@ -13,8 +15,30 @@ export type DesktopSyncVerificationDialogParams = {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [JslibModule, ButtonModule, DialogModule],
|
imports: [JslibModule, ButtonModule, DialogModule],
|
||||||
})
|
})
|
||||||
export class DesktopSyncVerificationDialogComponent {
|
export class DesktopSyncVerificationDialogComponent implements OnDestroy, OnInit {
|
||||||
constructor(@Inject(DIALOG_DATA) protected params: DesktopSyncVerificationDialogParams) {}
|
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) {
|
static open(dialogService: DialogService, data: DesktopSyncVerificationDialogParams) {
|
||||||
return dialogService.open(DesktopSyncVerificationDialogComponent, {
|
return dialogService.open(DesktopSyncVerificationDialogComponent, {
|
||||||
|
|||||||
@@ -3483,5 +3483,11 @@
|
|||||||
},
|
},
|
||||||
"upgradeOrganizationDesc": {
|
"upgradeOrganizationDesc": {
|
||||||
"message": "This feature is not available for free organizations. Switch to a paid plan to unlock more features."
|
"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),
|
getEphemeralValue: (key: string): Promise<string> => ipcRenderer.invoke("getEphemeralValue", key),
|
||||||
removeEphemeralValue: (key: string): Promise<void> =>
|
removeEphemeralValue: (key: string): Promise<void> =>
|
||||||
ipcRenderer.invoke("deleteEphemeralValue", key),
|
ipcRenderer.invoke("deleteEphemeralValue", key),
|
||||||
|
listEphemeralValueKeys: (): Promise<string[]> => ipcRenderer.invoke("listEphemeralValueKeys"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const localhostCallbackService = {
|
const localhostCallbackService = {
|
||||||
|
|||||||
@@ -17,5 +17,8 @@ export class EphemeralValueStorageService {
|
|||||||
ipcMain.handle("deleteEphemeralValue", async (event, key: string) => {
|
ipcMain.handle("deleteEphemeralValue", async (event, key: string) => {
|
||||||
this.ephemeralValues.delete(key);
|
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 { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { FakeAccountService } from "@bitwarden/common/spec";
|
||||||
|
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService, I18nMockService } from "@bitwarden/components";
|
||||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
import {
|
||||||
|
KeyService,
|
||||||
|
BiometricsService,
|
||||||
|
BiometricStateService,
|
||||||
|
BiometricsCommands,
|
||||||
|
} from "@bitwarden/key-management";
|
||||||
|
|
||||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||||
|
|
||||||
import { BiometricMessageHandlerService } from "./biometric-message-handler.service";
|
import { BiometricMessageHandlerService } from "./biometric-message-handler.service";
|
||||||
|
|
||||||
(global as any).ipc = {
|
|
||||||
platform: {
|
|
||||||
reloadProcess: jest.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SomeUser = "SomeUser" as UserId;
|
const SomeUser = "SomeUser" as UserId;
|
||||||
const AnotherUser = "SomeOtherUser" as UserId;
|
const AnotherUser = "SomeOtherUser" as UserId;
|
||||||
const accounts = {
|
const accounts = {
|
||||||
@@ -54,6 +55,7 @@ describe("BiometricMessageHandlerService", () => {
|
|||||||
let accountService: AccountService;
|
let accountService: AccountService;
|
||||||
let authService: MockProxy<AuthService>;
|
let authService: MockProxy<AuthService>;
|
||||||
let ngZone: MockProxy<NgZone>;
|
let ngZone: MockProxy<NgZone>;
|
||||||
|
let i18nService: MockProxy<I18nMockService>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||||
@@ -69,6 +71,24 @@ describe("BiometricMessageHandlerService", () => {
|
|||||||
accountService = new FakeAccountService(accounts);
|
accountService = new FakeAccountService(accounts);
|
||||||
authService = mock<AuthService>();
|
authService = mock<AuthService>();
|
||||||
ngZone = mock<NgZone>();
|
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(
|
service = new BiometricMessageHandlerService(
|
||||||
cryptoFunctionService,
|
cryptoFunctionService,
|
||||||
@@ -83,9 +103,256 @@ describe("BiometricMessageHandlerService", () => {
|
|||||||
accountService,
|
accountService,
|
||||||
authService,
|
authService,
|
||||||
ngZone,
|
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", () => {
|
describe("process reload", () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
// don't reload when the active user is the requested one and unlocked
|
// 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
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Injectable, NgZone } from "@angular/core";
|
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
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 MessageValidTimeout = 10 * 1000;
|
||||||
const HashAlgorithmForAsymmetricEncryption = "sha1";
|
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()
|
@Injectable()
|
||||||
export class BiometricMessageHandlerService {
|
export class BiometricMessageHandlerService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,7 +91,21 @@ export class BiometricMessageHandlerService {
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private ngZone: NgZone,
|
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) {
|
async handleMessage(msg: LegacyMessageWrapper) {
|
||||||
const { appId, message: rawMessage } = msg as LegacyMessageWrapper;
|
const { appId, message: rawMessage } = msg as LegacyMessageWrapper;
|
||||||
@@ -69,39 +128,24 @@ export class BiometricMessageHandlerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) {
|
if (await this.connectedApps.has(appId)) {
|
||||||
this.logService.info("[Native Messaging IPC] Requesting fingerprint verification.");
|
this.logService.info(
|
||||||
ipc.platform.nativeMessaging.sendMessage({
|
"[Native Messaging IPC] Public key for app id changed. Invalidating trust",
|
||||||
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) {
|
|
||||||
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);
|
await this.secureCommunication(remotePublicKey, appId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
|
if ((await this.connectedApps.get(appId))?.sessionSecret == null) {
|
||||||
this.logService.info(
|
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({
|
ipc.platform.nativeMessaging.sendMessage({
|
||||||
command: "invalidateEncryption",
|
command: "invalidateEncryption",
|
||||||
@@ -113,7 +157,7 @@ export class BiometricMessageHandlerService {
|
|||||||
const message: LegacyMessage = JSON.parse(
|
const message: LegacyMessage = JSON.parse(
|
||||||
await this.encryptService.decryptToUtf8(
|
await this.encryptService.decryptToUtf8(
|
||||||
rawMessage as EncString,
|
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
|
// TODO: legacy, remove after 2025.3
|
||||||
case BiometricsCommands.Unlock: {
|
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 =
|
const isTemporarilyDisabled =
|
||||||
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
|
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
|
||||||
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
|
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
|
||||||
@@ -283,7 +339,7 @@ export class BiometricMessageHandlerService {
|
|||||||
|
|
||||||
const encrypted = await this.encryptService.encrypt(
|
const encrypted = await this.encryptService.encrypt(
|
||||||
JSON.stringify(message),
|
JSON.stringify(message),
|
||||||
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
|
SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret),
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.platform.nativeMessaging.sendMessage({
|
ipc.platform.nativeMessaging.sendMessage({
|
||||||
@@ -295,10 +351,9 @@ export class BiometricMessageHandlerService {
|
|||||||
|
|
||||||
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
|
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
|
||||||
const secret = await this.cryptoFunctionService.randomBytes(64);
|
const secret = await this.cryptoFunctionService.randomBytes(64);
|
||||||
await ipc.platform.ephemeralStore.setEphemeralValue(
|
const connectedApp = await this.connectedApps.get(appId);
|
||||||
appId,
|
connectedApp.sessionSecret = new SymmetricCryptoKey(secret).keyB64;
|
||||||
new SymmetricCryptoKey(secret).keyB64,
|
await this.connectedApps.set(appId, connectedApp);
|
||||||
);
|
|
||||||
|
|
||||||
this.logService.info("[Native Messaging IPC] Setting up secure channel");
|
this.logService.info("[Native Messaging IPC] Setting up secure channel");
|
||||||
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
|
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
|
||||||
@@ -320,6 +375,18 @@ export class BiometricMessageHandlerService {
|
|||||||
appId: string,
|
appId: string,
|
||||||
) {
|
) {
|
||||||
const messageUserId = message.userId as UserId;
|
const messageUserId = message.userId as UserId;
|
||||||
|
if (!(await this.validateFingerprint(appId))) {
|
||||||
|
await this.send(
|
||||||
|
{
|
||||||
|
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||||
|
messageId,
|
||||||
|
response: false,
|
||||||
|
},
|
||||||
|
appId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
|
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
|
||||||
if (userKey != null) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,13 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="unlockOptions; else loading">
|
<ng-container *ngIf="unlockOptions; else loading">
|
||||||
<!-- Biometrics Unlock -->
|
<!-- Biometrics Unlock -->
|
||||||
<ng-container
|
<ng-container *ngIf="activeUnlockOption === UnlockOption.Biometrics">
|
||||||
*ngIf="unlockOptions.biometrics.enabled && activeUnlockOption === UnlockOption.Biometrics"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitButton
|
bitButton
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
class="tw-mb-3"
|
class="tw-mb-3"
|
||||||
[disabled]="unlockingViaBiometrics"
|
[disabled]="unlockingViaBiometrics || !biometricsAvailable"
|
||||||
[loading]="unlockingViaBiometrics"
|
[loading]="unlockingViaBiometrics"
|
||||||
block
|
block
|
||||||
(click)="unlockViaBiometrics()"
|
(click)="unlockViaBiometrics()"
|
||||||
|
|||||||
Reference in New Issue
Block a user