mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +00:00
[PM-18038] Fix safari using outdated biometrics protocol (#13287)
* Fix safari using outdated biometrics protocol * Remove logging * Remove log * Move canEnableBiometricUnlock to biometric service * Fix build * Add tests * Fix type error * Attempt to fix build * Fix build * Fix test failure
This commit is contained in:
@@ -233,11 +233,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
switchMap(async () => {
|
||||
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
|
||||
const biometricSettingAvailable =
|
||||
!(await BrowserApi.permissionsGranted(["nativeMessaging"])) ||
|
||||
(status !== BiometricsStatus.DesktopDisconnected &&
|
||||
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) ||
|
||||
(await this.vaultTimeoutSettingsService.isBiometricLockSet());
|
||||
const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock();
|
||||
if (!biometricSettingAvailable) {
|
||||
this.form.controls.biometric.disable({ emitEvent: false });
|
||||
} else {
|
||||
@@ -256,6 +252,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
"biometricsStatusHelptextNotEnabledInDesktop",
|
||||
activeAccount.email,
|
||||
);
|
||||
} else if (
|
||||
status === BiometricsStatus.HardwareUnavailable &&
|
||||
!biometricSettingAvailable
|
||||
) {
|
||||
this.biometricUnavailabilityReason = this.i18nService.t(
|
||||
"biometricsStatusHelptextHardwareUnavailable",
|
||||
);
|
||||
} else {
|
||||
this.biometricUnavailabilityReason = "";
|
||||
}
|
||||
|
||||
@@ -672,14 +672,6 @@ export default class MainBackground {
|
||||
this.kdfConfigService,
|
||||
);
|
||||
|
||||
this.biometricsService = new BackgroundBrowserBiometricsService(
|
||||
runtimeNativeMessagingBackground,
|
||||
this.logService,
|
||||
this.keyService,
|
||||
this.biometricStateService,
|
||||
this.messagingService,
|
||||
);
|
||||
|
||||
this.appIdService = new AppIdService(this.storageService, this.logService);
|
||||
|
||||
this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider);
|
||||
@@ -699,6 +691,15 @@ export default class MainBackground {
|
||||
VaultTimeoutStringType.OnRestart, // default vault timeout
|
||||
);
|
||||
|
||||
this.biometricsService = new BackgroundBrowserBiometricsService(
|
||||
runtimeNativeMessagingBackground,
|
||||
this.logService,
|
||||
this.keyService,
|
||||
this.biometricStateService,
|
||||
this.messagingService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
);
|
||||
|
||||
this.apiService = new ApiService(
|
||||
this.tokenService,
|
||||
this.platformUtilsService,
|
||||
|
||||
@@ -120,9 +120,15 @@ export class NativeMessagingBackground {
|
||||
this.connecting = true;
|
||||
|
||||
const connectedCallback = () => {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
|
||||
);
|
||||
if (!this.platformUtilsService.isSafari()) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
|
||||
);
|
||||
} else {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Connection to Safari swift module established!",
|
||||
);
|
||||
}
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
resolve();
|
||||
@@ -131,6 +137,7 @@ export class NativeMessagingBackground {
|
||||
// Safari has a bundled native component which is always available, no need to
|
||||
// check if the desktop app is running.
|
||||
if (this.platformUtilsService.isSafari()) {
|
||||
this.isConnectedToOutdatedDesktopClient = false;
|
||||
connectedCallback();
|
||||
}
|
||||
|
||||
@@ -428,7 +435,9 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId)!.resolver(message);
|
||||
const callback = this.callbacks!.get(messageId)!;
|
||||
this.callbacks.delete(messageId);
|
||||
callback.resolver(message);
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export default class RuntimeBackground {
|
||||
BiometricsCommands.GetBiometricsStatus,
|
||||
BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
BiometricsCommands.GetBiometricsStatusForUser,
|
||||
BiometricsCommands.CanEnableBiometricUnlock,
|
||||
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||
"getInlineMenuFieldQualificationFeatureFlag",
|
||||
"getUserPremiumStatus",
|
||||
@@ -201,6 +202,9 @@ export default class RuntimeBackground {
|
||||
case BiometricsCommands.GetBiometricsStatusForUser: {
|
||||
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
|
||||
}
|
||||
case BiometricsCommands.CanEnableBiometricUnlock: {
|
||||
return await this.main.biometricsService.canEnableBiometricUnlock();
|
||||
}
|
||||
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
|
||||
return await this.configService.getFeatureFlag(
|
||||
FeatureFlag.UseTreeWalkerApiForPageDetailsCollection,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
|
||||
|
||||
import { BackgroundBrowserBiometricsService } from "./background-browser-biometrics.service";
|
||||
|
||||
describe("background browser biometrics service tests", function () {
|
||||
let service: BackgroundBrowserBiometricsService;
|
||||
|
||||
const nativeMessagingBackground = mock<NativeMessagingBackground>();
|
||||
const logService = mock<LogService>();
|
||||
const keyService = mock<KeyService>();
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
const messagingService = mock<MessagingService>();
|
||||
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new BackgroundBrowserBiometricsService(
|
||||
() => nativeMessagingBackground,
|
||||
logService,
|
||||
keyService,
|
||||
biometricStateService,
|
||||
messagingService,
|
||||
vaultTimeoutSettingsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("canEnableBiometricUnlock", () => {
|
||||
const table: [BiometricsStatus, boolean, boolean][] = [
|
||||
// status, already enabled, expected
|
||||
|
||||
// if the setting is not already on, it should only be possible to enable it if biometrics are available
|
||||
[BiometricsStatus.Available, false, true],
|
||||
[BiometricsStatus.HardwareUnavailable, false, false],
|
||||
[BiometricsStatus.NotEnabledInConnectedDesktopApp, false, false],
|
||||
[BiometricsStatus.DesktopDisconnected, false, false],
|
||||
|
||||
// if the setting is already on, it should always be possible to disable it
|
||||
[BiometricsStatus.Available, true, true],
|
||||
[BiometricsStatus.HardwareUnavailable, true, true],
|
||||
[BiometricsStatus.NotEnabledInConnectedDesktopApp, true, true],
|
||||
[BiometricsStatus.DesktopDisconnected, true, true],
|
||||
];
|
||||
test.each(table)(
|
||||
"status: %s, already enabled: %s, expected: %s",
|
||||
async (status, alreadyEnabled, expected) => {
|
||||
service.getBiometricsStatus = jest.fn().mockResolvedValue(status);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(alreadyEnabled);
|
||||
const result = await service.canEnableBiometricUnlock();
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
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";
|
||||
@@ -25,6 +26,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
private keyService: KeyService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private messagingService: MessagingService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -169,4 +171,14 @@ export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
const status = await this.getBiometricsStatus();
|
||||
const isBiometricsAlreadyEnabled = await this.vaultTimeoutSettingsService.isBiometricLockSet();
|
||||
const statusAllowsBiometric =
|
||||
status !== BiometricsStatus.DesktopDisconnected &&
|
||||
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp &&
|
||||
status !== BiometricsStatus.HardwareUnavailable;
|
||||
|
||||
return statusAllowsBiometric || isBiometricsAlreadyEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { ForegroundBrowserBiometricsService } from "./foreground-browser-biometrics";
|
||||
|
||||
jest.mock("../../platform/browser/browser-api", () => ({
|
||||
BrowserApi: {
|
||||
sendMessageWithResponse: jest.fn(),
|
||||
permissionsGranted: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("foreground browser biometrics service tests", function () {
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("canEnableBiometricUnlock", () => {
|
||||
const table: [boolean, boolean, boolean, boolean][] = [
|
||||
// canEnableBiometricUnlock from background, native permission granted, isSafari, expected
|
||||
|
||||
// needs permission prompt; always allowed
|
||||
[true, false, false, true],
|
||||
[false, false, false, true],
|
||||
|
||||
// is safari; depends on the status that the background service reports
|
||||
[false, false, true, false],
|
||||
[true, false, true, true],
|
||||
|
||||
// native permissions granted; depends on the status that the background service reports
|
||||
[false, true, false, false],
|
||||
[true, true, false, true],
|
||||
|
||||
// should never happen since safari does not use the permissions
|
||||
[false, true, true, false],
|
||||
[true, true, true, true],
|
||||
];
|
||||
test.each(table)(
|
||||
"canEnableBiometric: %s, native permission granted: %s, isSafari: %s, expected: %s",
|
||||
async (canEnableBiometricUnlockBackground, granted, isSafari, expected) => {
|
||||
const service = new ForegroundBrowserBiometricsService(platformUtilsService);
|
||||
|
||||
(BrowserApi.permissionsGranted as jest.Mock).mockResolvedValue(granted);
|
||||
(BrowserApi.sendMessageWithResponse as jest.Mock).mockResolvedValue({
|
||||
result: canEnableBiometricUnlockBackground,
|
||||
});
|
||||
platformUtilsService.isSafari.mockReturnValue(isSafari);
|
||||
|
||||
const result = await service.canEnableBiometricUnlock();
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -8,6 +9,10 @@ import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
export class ForegroundBrowserBiometricsService extends BiometricsService {
|
||||
shouldAutopromptNow = true;
|
||||
|
||||
constructor(private platformUtilsService: PlatformUtilsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
@@ -52,4 +57,19 @@ export class ForegroundBrowserBiometricsService extends BiometricsService {
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
this.shouldAutopromptNow = value;
|
||||
}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
const needsPermissionPrompt =
|
||||
!(await BrowserApi.permissionsGranted(["nativeMessaging"])) &&
|
||||
!this.platformUtilsService.isSafari();
|
||||
return (
|
||||
needsPermissionPrompt ||
|
||||
(
|
||||
await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
error: string;
|
||||
}>(BiometricsCommands.CanEnableBiometricUnlock)
|
||||
).result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,10 +316,8 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BiometricsService,
|
||||
useFactory: () => {
|
||||
return new ForegroundBrowserBiometricsService();
|
||||
},
|
||||
deps: [],
|
||||
useClass: ForegroundBrowserBiometricsService,
|
||||
deps: [PlatformUtilsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SyncService,
|
||||
|
||||
@@ -152,7 +152,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"command": "unlockWithBiometricsForUser",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
@@ -177,7 +177,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"command": "unlockWithBiometricsForUser",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
@@ -209,7 +209,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"command": "unlockWithBiometricsForUser",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"userKeyB64": result!.replacingOccurrences(of: "\"", with: ""),
|
||||
@@ -220,7 +220,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"command": "unlockWithBiometricsForUser",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
|
||||
@@ -24,4 +24,7 @@ export class CliBiometricsService extends BiometricsService {
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@ describe("SettingsComponent", () => {
|
||||
describe("biometrics enabled", () => {
|
||||
beforeEach(() => {
|
||||
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
|
||||
desktopBiometricsService.canEnableBiometricUnlock.mockResolvedValue(true);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -388,24 +388,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.supportsBiometric = this.shouldAllowBiometricSetup(
|
||||
await this.biometricsService.getBiometricsStatus(),
|
||||
);
|
||||
this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock();
|
||||
this.timerId = setInterval(async () => {
|
||||
this.supportsBiometric = this.shouldAllowBiometricSetup(
|
||||
await this.biometricsService.getBiometricsStatus(),
|
||||
);
|
||||
this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean {
|
||||
return [
|
||||
BiometricsStatus.Available,
|
||||
BiometricsStatus.AutoSetupNeeded,
|
||||
BiometricsStatus.ManualSetupNeeded,
|
||||
].includes(biometricStatus);
|
||||
}
|
||||
|
||||
async saveVaultTimeout(newValue: VaultTimeout) {
|
||||
if (newValue === VaultTimeoutStringType.Never) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
|
||||
@@ -163,4 +163,8 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutoPrompt;
|
||||
}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { RendererBiometricsService } from "./renderer-biometrics.service";
|
||||
|
||||
describe("renderer biometrics service tests", function () {
|
||||
beforeEach(() => {
|
||||
(global as any).ipc = {
|
||||
keyManagement: {
|
||||
biometric: {
|
||||
authenticateWithBiometrics: jest.fn(),
|
||||
getBiometricsStatus: jest.fn(),
|
||||
unlockWithBiometricsForUser: jest.fn(),
|
||||
getBiometricsStatusForUser: jest.fn(),
|
||||
deleteBiometricUnlockKeyForUser: jest.fn(),
|
||||
setupBiometrics: jest.fn(),
|
||||
setClientKeyHalfForUser: jest.fn(),
|
||||
getShouldAutoprompt: jest.fn(),
|
||||
setShouldAutoprompt: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("canEnableBiometricUnlock", () => {
|
||||
const table: [BiometricsStatus, boolean][] = [
|
||||
[BiometricsStatus.Available, true],
|
||||
[BiometricsStatus.AutoSetupNeeded, true],
|
||||
[BiometricsStatus.ManualSetupNeeded, true],
|
||||
|
||||
[BiometricsStatus.UnlockNeeded, false],
|
||||
[BiometricsStatus.HardwareUnavailable, false],
|
||||
[BiometricsStatus.PlatformUnsupported, false],
|
||||
[BiometricsStatus.NotEnabledLocally, false],
|
||||
];
|
||||
test.each(table)("canEnableBiometricUnlock(%s) === %s", async (status, expected) => {
|
||||
const service = new RendererBiometricsService();
|
||||
(global as any).ipc.keyManagement.biometric.getBiometricsStatus.mockResolvedValue(status);
|
||||
|
||||
const result = await service.canEnableBiometricUnlock();
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,13 @@ export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setShouldAutoprompt(value);
|
||||
}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
const biometricStatus = await this.getBiometricsStatus();
|
||||
return [
|
||||
BiometricsStatus.Available,
|
||||
BiometricsStatus.AutoSetupNeeded,
|
||||
BiometricsStatus.ManualSetupNeeded,
|
||||
].includes(biometricStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService {
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
|
||||
async canEnableBiometricUnlock(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,5 @@ export abstract class BiometricsService {
|
||||
|
||||
abstract getShouldAutopromptNow(): Promise<boolean>;
|
||||
abstract setShouldAutopromptNow(value: boolean): Promise<void>;
|
||||
abstract canEnableBiometricUnlock(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export enum BiometricsCommands {
|
||||
/** Get biometric status for a specific user account. This includes both information about availability of cryptographic material (is the user configured for biometric unlock? is a masterpassword unlock needed? But also information about the biometric system's availability in a single status) */
|
||||
GetBiometricsStatusForUser = "getBiometricsStatusForUser",
|
||||
|
||||
/** Checks whether the biometric unlock can be enabled. */
|
||||
CanEnableBiometricUnlock = "canEnableBiometricUnlock",
|
||||
|
||||
// legacy
|
||||
Unlock = "biometricUnlock",
|
||||
IsAvailable = "biometricUnlockAvailable",
|
||||
|
||||
Reference in New Issue
Block a user