1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-26340] Enable linux biometrics v2 (#16661)

This commit is contained in:
Bernd Schoolmann
2025-10-31 22:47:17 +01:00
committed by GitHub
parent b5a7379ea9
commit e68a471655
11 changed files with 262 additions and 1 deletions

View File

@@ -18,4 +18,7 @@ export abstract class DesktopBiometricsService extends BiometricsService {
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
abstract enableWindowsV2Biometrics(): Promise<void>;
abstract isWindowsV2BiometricsEnabled(): Promise<boolean>;
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
abstract enableLinuxV2Biometrics(): Promise<void>;
abstract isLinuxV2BiometricsEnabled(): Promise<boolean>;
}

View File

@@ -62,6 +62,10 @@ export class MainBiometricsIPCListener {
return await this.biometricService.enableWindowsV2Biometrics();
case BiometricAction.IsWindowsV2Enabled:
return await this.biometricService.isWindowsV2BiometricsEnabled();
case BiometricAction.EnableLinuxV2:
return await this.biometricService.enableLinuxV2Biometrics();
case BiometricAction.IsLinuxV2Enabled:
return await this.biometricService.isLinuxV2BiometricsEnabled();
default:
return;
}

View File

@@ -10,13 +10,14 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import { LinuxBiometricsSystem, WindowsBiometricsSystem } from "./native-v2";
import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private shouldAutoPrompt = true;
private windowsV2BiometricsEnabled = false;
private linuxV2BiometricsEnabled = false;
constructor(
private i18nService: I18nService,
@@ -170,4 +171,16 @@ export class MainBiometricsService extends DesktopBiometricsService {
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return this.windowsV2BiometricsEnabled;
}
async enableLinuxV2Biometrics(): Promise<void> {
if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) {
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux");
this.osBiometricsService = new LinuxBiometricsSystem();
this.linuxV2BiometricsEnabled = true;
}
}
async isLinuxV2BiometricsEnabled(): Promise<boolean> {
return this.linuxV2BiometricsEnabled;
}
}

View File

@@ -1 +1,2 @@
export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service";
export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service";

View File

@@ -0,0 +1,96 @@
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics_v2: {
initBiometricSystem: jest.fn(() => "mockSystem"),
provideKey: jest.fn(),
unenroll: jest.fn(),
unlock: jest.fn(),
authenticate: jest.fn(),
authenticateAvailable: jest.fn(),
unlockAvailable: jest.fn(),
},
passwords: {
isAvailable: jest.fn(),
},
}));
const mockKey = new Uint8Array(64);
jest.mock("../../../utils", () => ({
isFlatpak: jest.fn(() => false),
isLinux: jest.fn(() => true),
isSnapStore: jest.fn(() => false),
}));
describe("OsBiometricsServiceLinux", () => {
const userId = "user-id" as UserId;
const key = { toEncoded: () => ({ buffer: Buffer.from(mockKey) }) } as SymmetricCryptoKey;
let service: OsBiometricsServiceLinux;
beforeEach(() => {
service = new OsBiometricsServiceLinux();
jest.clearAllMocks();
});
it("should set biometric key", async () => {
await service.setBiometricKey(userId, key);
expect(biometrics_v2.provideKey).toHaveBeenCalled();
});
it("should delete biometric key", async () => {
await service.deleteBiometricKey(userId);
expect(biometrics_v2.unenroll).toHaveBeenCalled();
});
it("should get biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey);
const result = await service.getBiometricKey(userId);
expect(result).toBeInstanceOf(SymmetricCryptoKey);
});
it("should return null if no biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(null);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
});
it("should authenticate biometric", async () => {
(biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true);
const result = await service.authenticateBiometric();
expect(result).toBe(true);
});
it("should check if biometrics is supported", async () => {
(passwords.isAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should check if setup is needed", async () => {
(biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(false);
const result = await service.needsSetup();
expect(result).toBe(true);
});
it("should check if can auto setup", async () => {
const result = await service.canAutoSetup();
expect(result).toBe(true);
});
it("should get biometrics first unlock status for user", async () => {
(biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return false for hasPersistentKey", async () => {
const result = await service.hasPersistentKey(userId);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
import { spawn } from "child_process";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { isSnapStore, isFlatpak, isLinux } from "../../../utils";
import { OsBiometricService } from "../os-biometrics.service";
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="com.bitwarden.Bitwarden.unlock">
<description>Unlock Bitwarden</description>
<message>Authenticate to unlock Bitwarden</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self</allow_active>
</defaults>
</action>
</policyconfig>`;
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
export default class OsBiometricsServiceLinux implements OsBiometricService {
private biometricsSystem: biometrics_v2.BiometricLockSystem;
constructor() {
this.biometricsSystem = biometrics_v2.initBiometricSystem();
}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
await biometrics_v2.unenroll(this.biometricsSystem, userId);
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from(""));
return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null;
}
async authenticateBiometric(): Promise<boolean> {
return await biometrics_v2.authenticate(
this.biometricsSystem,
Buffer.from(""),
"Authenticate to unlock",
);
}
async supportsBiometrics(): Promise<boolean> {
// We assume all linux distros have some polkit implementation
// that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup.
// Snap does not have access at the moment to polkit
// This could be dynamically detected on dbus in the future.
// We should check if a libsecret implementation is available on the system
// because otherwise we cannot offlod the protected userkey to secure storage.
return await passwords.isAvailable();
}
async needsSetup(): Promise<boolean> {
if (isSnapStore()) {
return false;
}
// check whether the polkit policy is loaded via dbus call to polkit
return !(await biometrics_v2.authenticateAvailable(this.biometricsSystem));
}
async canAutoSetup(): Promise<boolean> {
// We cannot auto setup on snap or flatpak since the filesystem is sandboxed.
// The user needs to manually set up the polkit policy outside of the sandbox
// since we allow access to polkit via dbus for the sandboxed clients, the authentication works from
// the sandbox, once the policy is set up outside of the sandbox.
return isLinux() && !isSnapStore() && !isFlatpak();
}
async runSetup(): Promise<void> {
const process = spawn("pkexec", [
"bash",
"-c",
`echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`,
]);
await new Promise((resolve, reject) => {
process.on("close", (code) => {
if (code !== 0) {
reject("Failed to set up polkit policy");
} else {
resolve(null);
}
});
});
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
}

View File

@@ -84,4 +84,12 @@ export class RendererBiometricsService extends DesktopBiometricsService {
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
}
async enableLinuxV2Biometrics(): Promise<void> {
return await ipc.keyManagement.biometric.enableLinuxV2Biometrics();
}
async isLinuxV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isLinuxV2BiometricsEnabled();
}
}

View File

@@ -69,6 +69,14 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsWindowsV2Enabled,
} satisfies BiometricMessage),
enableLinuxV2Biometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnableLinuxV2,
} satisfies BiometricMessage),
isLinuxV2BiometricsEnabled: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsLinuxV2Enabled,
} satisfies BiometricMessage),
};
export default {

View File

@@ -125,6 +125,11 @@ export class BiometricMessageHandlerService {
if (windowsV2Enabled) {
await this.biometricsService.enableWindowsV2Biometrics();
}
const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2);
if (linuxV2Enabled) {
await this.biometricsService.enableLinuxV2Biometrics();
}
}
async handleMessage(msg: LegacyMessageWrapper) {

View File

@@ -19,6 +19,9 @@ export enum BiometricAction {
EnableWindowsV2 = "enableWindowsV2",
IsWindowsV2Enabled = "isWindowsV2Enabled",
EnableLinuxV2 = "enableLinuxV2",
IsLinuxV2Enabled = "isLinuxV2Enabled",
}
export type BiometricMessage =

View File

@@ -37,6 +37,7 @@ export enum FeatureFlag {
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
@@ -124,6 +125,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.WindowsBiometricsV2]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,