1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-07 11:03:30 +00:00

[PM-10741] Refactor biometrics interface & add dynamic status (#10973)

This commit is contained in:
Bernd Schoolmann
2025-01-08 10:46:00 +01:00
committed by GitHub
parent 0bd988dac8
commit 72121cda94
66 changed files with 1840 additions and 1459 deletions

View File

@@ -0,0 +1,123 @@
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 { FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricStateService } 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 = {
[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<CryptoFunctionService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let logService: MockProxy<LogService>;
let messagingService: MockProxy<MessagingService>;
let desktopSettingsService: DesktopSettingsService;
let biometricStateService: BiometricStateService;
let biometricsService: MockProxy<BiometricsService>;
let dialogService: MockProxy<DialogService>;
let accountService: AccountService;
let authService: MockProxy<AuthService>;
let ngZone: MockProxy<NgZone>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
logService = mock<LogService>();
messagingService = mock<MessagingService>();
desktopSettingsService = mock<DesktopSettingsService>();
biometricStateService = mock<BiometricStateService>();
biometricsService = mock<BiometricsService>();
dialogService = mock<DialogService>();
accountService = new FakeAccountService(accounts);
authService = mock<AuthService>();
ngZone = mock<NgZone>();
service = new BiometricMessageHandlerService(
cryptoFunctionService,
keyService,
encryptService,
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
);
});
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();
}
},
);
});
});

View File

@@ -10,13 +10,18 @@ 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 { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import {
BiometricStateService,
BiometricsCommands,
BiometricsService,
BiometricsStatus,
KeyService,
} from "@bitwarden/key-management";
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
import { LegacyMessage } from "../models/native-messaging/legacy-message";
@@ -54,6 +59,9 @@ export class BiometricMessageHandlerService {
const accounts = await firstValueFrom(this.accountService.accounts$);
const userIds = Object.keys(accounts);
if (!userIds.includes(rawMessage.userId)) {
this.logService.info(
"[Native Messaging IPC] Received message for user that is not logged into the desktop app.",
);
ipc.platform.nativeMessaging.sendMessage({
command: "wrongUserId",
appId: appId,
@@ -62,6 +70,7 @@ export class BiometricMessageHandlerService {
}
if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) {
this.logService.info("[Native Messaging IPC] Requesting fingerprint verification.");
ipc.platform.nativeMessaging.sendMessage({
command: "verifyFingerprint",
appId: appId,
@@ -81,6 +90,7 @@ export class BiometricMessageHandlerService {
const browserSyncVerified = await firstValueFrom(dialogRef.closed);
if (browserSyncVerified !== true) {
this.logService.info("[Native Messaging IPC] Fingerprint verification failed.");
return;
}
}
@@ -90,6 +100,9 @@ export class BiometricMessageHandlerService {
}
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
this.logService.info(
"[Native Messaging IPC] Epheremal secret for secure channel is missing. Invalidating encryption...",
);
ipc.platform.nativeMessaging.sendMessage({
command: "invalidateEncryption",
appId: appId,
@@ -106,6 +119,9 @@ export class BiometricMessageHandlerService {
// Shared secret is invalidated, force re-authentication
if (message == null) {
this.logService.info(
"[Native Messaging IPC] Secure channel failed to decrypt message. Invalidating encryption...",
);
ipc.platform.nativeMessaging.sendMessage({
command: "invalidateEncryption",
appId: appId,
@@ -114,20 +130,86 @@ export class BiometricMessageHandlerService {
}
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
this.logService.error("NativeMessage is to old, ignoring.");
this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
return;
}
const messageId = message.messageId;
switch (message.command) {
case "biometricUnlock": {
case BiometricsCommands.UnlockWithBiometricsForUser: {
await this.handleUnlockWithBiometricsForUser(message, messageId, appId);
break;
}
case BiometricsCommands.AuthenticateWithBiometrics: {
try {
const unlocked = await this.biometricsService.authenticateWithBiometrics();
await this.send(
{
command: BiometricsCommands.AuthenticateWithBiometrics,
messageId,
response: unlocked,
},
appId,
);
} catch (e) {
this.logService.error("[Native Messaging IPC] Biometric authentication failed", e);
await this.send(
{ command: BiometricsCommands.AuthenticateWithBiometrics, messageId, response: false },
appId,
);
}
break;
}
case BiometricsCommands.GetBiometricsStatus: {
const status = await this.biometricsService.getBiometricsStatus();
return this.send(
{
command: BiometricsCommands.GetBiometricsStatus,
messageId,
response: status,
},
appId,
);
}
case BiometricsCommands.GetBiometricsStatusForUser: {
let status = await this.biometricsService.getBiometricsStatusForUser(
message.userId as UserId,
);
if (status == BiometricsStatus.NotEnabledLocally) {
status = BiometricsStatus.NotEnabledInConnectedDesktopApp;
}
return this.send(
{
command: BiometricsCommands.GetBiometricsStatusForUser,
messageId,
response: status,
},
appId,
);
}
// TODO: legacy, remove after 2025.01
case BiometricsCommands.IsAvailable: {
const available =
(await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available;
return this.send(
{
command: BiometricsCommands.IsAvailable,
response: available ? "available" : "not available",
},
appId,
);
}
// TODO: legacy, remove after 2025.01
case BiometricsCommands.Unlock: {
const isTemporarilyDisabled =
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
!(await this.biometricsService.supportsBiometric());
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
if (isTemporarilyDisabled) {
return this.send({ command: "biometricUnlock", response: "not available" }, appId);
}
if (!(await this.biometricsService.supportsBiometric())) {
if (!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available)) {
return this.send({ command: "biometricUnlock", response: "not supported" }, appId);
}
@@ -158,10 +240,7 @@ export class BiometricMessageHandlerService {
}
try {
const userKey = await this.keyService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
message.userId,
);
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
if (userKey != null) {
await this.send(
@@ -189,19 +268,8 @@ export class BiometricMessageHandlerService {
} catch (e) {
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
}
break;
}
case "biometricUnlockAvailable": {
const isAvailable = await this.biometricsService.supportsBiometric();
return this.send(
{
command: "biometricUnlockAvailable",
response: isAvailable ? "available" : "not available",
},
appId,
);
}
default:
this.logService.error("NativeMessage, got unknown command: " + message.command);
break;
@@ -216,7 +284,11 @@ export class BiometricMessageHandlerService {
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
);
ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted });
ipc.platform.nativeMessaging.sendMessage({
appId: appId,
messageId: message.messageId,
message: encrypted,
});
}
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
@@ -226,6 +298,7 @@ export class BiometricMessageHandlerService {
new SymmetricCryptoKey(secret).keyB64,
);
this.logService.info("[Native Messaging IPC] Setting up secure channel");
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
secret,
remotePublicKey,
@@ -234,7 +307,62 @@ export class BiometricMessageHandlerService {
ipc.platform.nativeMessaging.sendMessage({
appId: appId,
command: "setupEncryption",
messageId: -1, // to indicate to the other side that this is a new desktop client. refactor later to use proper versioning
sharedSecret: Utils.fromBufferToB64(encryptedSecret),
});
}
private async handleUnlockWithBiometricsForUser(
message: LegacyMessage,
messageId: number,
appId: string,
) {
const messageUserId = message.userId as UserId;
try {
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
if (userKey != null) {
this.logService.info("[Native Messaging IPC] Biometric unlock for user: " + messageUserId);
await this.send(
{
command: BiometricsCommands.UnlockWithBiometricsForUser,
response: true,
messageId,
userKeyB64: userKey.keyB64,
},
appId,
);
await this.processReloadWhenRequired(messageUserId);
} else {
await this.send(
{
command: BiometricsCommands.UnlockWithBiometricsForUser,
messageId,
response: false,
},
appId,
);
}
} catch (e) {
await this.send(
{ command: BiometricsCommands.UnlockWithBiometricsForUser, messageId, response: false },
appId,
);
}
}
/** A process reload after a biometric unlock should happen if the userkey that was used for biometric unlock is for a different user than the
* currently active account. The userkey for the active account was in memory anyways. Further, if the desktop app is locked, a reload should occur (since the userkey was not already in memory).
*/
async processReloadWhenRequired(messageUserId: UserId) {
const currentlyActiveAccountId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const isCurrentlyActiveAccountUnlocked =
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
AuthenticationStatus.Unlocked;
if (currentlyActiveAccountId !== messageUserId || !isCurrentlyActiveAccountUnlocked) {
if (!ipc.platform.isDev) {
ipc.platform.reloadProcess();
}
}
}
}