1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 01:03:35 +00:00

[PM-990] Unix biometrics unlock via Polkit (#4586)

* Update unix biometrics for desktop biometrics rework

* Implement polkit policy setup

* Enable browser integration on Linux

* Remove polkit policy file

* Undo change to messages.json

* Fix biometrics setup, implement missing functions

* Implement osSupportsBiometrics

* Fix polkit settings message

* Remove unwraps in biometrics unix rust module

* Force password reprompt on start on linux with biometrics

* Merge branch 'main' into feature/unix-biometrics

* Allow browser extension to be unlocked on Linux via Polkit

* Implement availability check

* Cleanup

* Add auto-setup, manual setup, setup detection and change localized prompts

* Implement missing methods

* Add i18n to polkit message

* Implement missing method

* Small cleanup

* Update polkit consent message

* Fix unlock and print errors on failed biometrics

* Add dependencies to core crate

* Fix reference and update polkit policy

* Remove async-trait

* Add tsdoc

* Add comment about auto setup

* Delete unused init

* Update help link

* Remove additional settings for polkit

* Add availability-check to passwords implementation on linux

* Add availability test

* Add availability check to libsecret

* Expose availability check in napi crate

* Update d.ts

* Update osSupportsBiometric check to detect libsecret presence

* Improve secret service detection

* Add client half to Linux biometrics

* Fix windows build

* Remove unencrypted key handling for biometric key

* Move rng to rust, align linux bio implementation with windows

* Consolidate elevated commands into one

* Disable snap support in linux biometrics

---------

Co-authored-by: DigitallyRefined <129616584+DigitallyRefined@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2024-08-06 17:04:17 +02:00
committed by GitHub
parent 320e4f18ce
commit 2ce8500391
29 changed files with 557 additions and 80 deletions

View File

@@ -126,11 +126,14 @@
{{ biometricText | i18n }}
</label>
</div>
<small class="help-block" *ngIf="this.form.value.biometric">{{
<small class="help-block" *ngIf="this.form.value.biometric && !this.isLinux">{{
additionalBiometricSettingsText | i18n
}}</small>
</div>
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
<div
class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && !this.isLinux"
>
<div class="checkbox form-group-child">
<label for="autoPromptBiometrics">
<input
@@ -148,7 +151,8 @@
*ngIf="
supportsBiometric &&
this.form.value.biometric &&
(userHasMasterPassword || (this.form.value.pin && userHasPinSet))
(userHasMasterPassword || (this.form.value.pin && userHasPinSet)) &&
!this.isLinux
"
>
<div class="checkbox form-group-child">

View File

@@ -55,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
isWindows: boolean;
isLinux: boolean;
enableTrayText: string;
enableTrayDescText: string;
@@ -197,6 +198,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop;
this.isLinux = (await this.platformUtilsService.getDevice()) === DeviceType.LinuxDesktop;
if ((await this.stateService.getUserId()) == null) {
return;
@@ -464,6 +466,26 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
const needsSetup = await this.platformUtilsService.biometricsNeedsSetup();
const supportsBiometricAutoSetup =
await this.platformUtilsService.biometricsSupportsAutoSetup();
if (needsSetup) {
if (supportsBiometricAutoSetup) {
await this.platformUtilsService.biometricsSetup();
} else {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
}
}
await this.biometricStateService.setBiometricUnlockEnabled(true);
if (this.isWindows) {
// Recommended settings for Windows Hello
@@ -472,6 +494,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
} else if (this.isLinux) {
// Similar to Windows
this.form.controls.requirePasswordOnStart.setValue(true);
this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
}
await this.cryptoService.refreshAdditionalKeys();
@@ -624,7 +653,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.enableBrowserIntegration.setValue(false);
return;
} else if (ipc.platform.deviceType === DeviceType.LinuxDesktop) {
} else if (ipc.platform.isSnapStore || ipc.platform.isFlatpak) {
await this.dialogService.openSimpleDialog({
title: { key: "browserIntegrationUnsupportedTitle" },
content: { key: "browserIntegrationLinuxDesc" },
@@ -735,6 +764,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}
@@ -746,6 +777,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
return "autoPromptTouchId";
case DeviceType.WindowsDesktop:
return "autoPromptWindowsHello";
case DeviceType.LinuxDesktop:
return "autoPromptPolkit";
default:
throw new Error("Unsupported platform");
}

View File

@@ -217,6 +217,8 @@ export class LockComponent extends BaseLockComponent implements OnInit, OnDestro
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}

View File

@@ -1510,9 +1510,15 @@
"additionalWindowsHelloSettings": {
"message": "Additional Windows Hello settings"
},
"unlockWithPolkit": {
"message": "Unlock with system authentication"
},
"windowsHelloConsentMessage": {
"message": "Verify for Bitwarden."
},
"polkitConsentMessage": {
"message": "Authenticate to unlock Bitwarden."
},
"unlockWithTouchId": {
"message": "Unlock with Touch ID"
},
@@ -1525,6 +1531,9 @@
"autoPromptWindowsHello": {
"message": "Ask for Windows Hello on app start"
},
"autoPromptPolkit": {
"message": "Ask for system authentication on launch"
},
"autoPromptTouchId": {
"message": "Ask for Touch ID on app start"
},
@@ -1804,6 +1813,12 @@
"biometricsNotEnabledDesc": {
"message": "Browser biometrics requires desktop biometrics to be set up in the settings first."
},
"biometricsManualSetupTitle": {
"message": "Autometic setup not available"
},
"biometricsManualSetupDesc": {
"message": "Due to the installation method, biometrics support could not be automatically enabled. Would you like to open the documentation on how to do this manually?"
},
"personalOwnershipSubmitError": {
"message": "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections."
},

View File

@@ -51,4 +51,14 @@ export default class BiometricDarwinMain implements OsBiometricService {
return false;
}
}
async osBiometricsNeedsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
}

View File

@@ -9,6 +9,16 @@ export default class NoopBiometricsService implements OsBiometricService {
return false;
}
async osBiometricsNeedsSetup(): Promise<boolean> {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
async getBiometricKey(
service: string,
storageKey: string,

View File

@@ -0,0 +1,160 @@
import { spawn } from "child_process";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { WindowMain } from "../../../main/window.main";
import { isFlatpak, isLinux, isSnapStore } from "../../../utils";
import { OsBiometricService } from "./biometrics.service.abstraction";
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 BiometricUnixMain implements OsBiometricService {
constructor(
private i18nservice: I18nService,
private windowMain: WindowMain,
) {}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
async setBiometricKey(
service: string,
key: string,
value: string,
clientKeyPartB64: string | undefined,
): Promise<void> {
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
await biometrics.setBiometricSecret(
service,
key,
value,
storageDetails.key_material,
storageDetails.ivB64,
);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
await passwords.deletePassword(service, key);
}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyPartB64: string | undefined,
): Promise<string | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
const value = await passwords.getPassword(service, storageKey);
if (value == null || value == "") {
return null;
} else {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storedValue = await biometrics.getBiometricSecret(
service,
storageKey,
storageDetails.key_material,
);
return storedValue;
}
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nservice.t("polkitConsentMessage"));
}
async osSupportsBiometric(): 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()) && !isSnapStore();
}
async osBiometricsNeedsSetup(): Promise<boolean> {
// check whether the polkit policy is loaded via dbus call to polkit
return !(await biometrics.available());
}
async osBiometricsCanAutoSetup(): 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 osBiometricsSetup(): 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);
}
});
});
}
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
// when we want to force a re-derive of the key material.
private setIv(iv: string) {
this._iv = iv;
this._osKeyHalf = null;
}
private async getStorageDetails({
clientKeyHalfB64,
}: {
clientKeyHalfB64: string;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verefication!
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
return {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
}
}

View File

@@ -214,4 +214,14 @@ export default class BiometricWindowsMain implements OsBiometricService {
clientKeyPartB64,
};
}
async osBiometricsNeedsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
}

View File

@@ -1,5 +1,8 @@
export abstract class BiometricsServiceAbstraction {
abstract osSupportsBiometric(): Promise<boolean>;
abstract osBiometricsNeedsSetup: () => Promise<boolean>;
abstract osBiometricsCanAutoSetup: () => Promise<boolean>;
abstract osBiometricsSetup: () => Promise<void>;
abstract canAuthBiometric({
service,
key,
@@ -26,6 +29,22 @@ export abstract class BiometricsServiceAbstraction {
export interface OsBiometricService {
osSupportsBiometric(): Promise<boolean>;
/**
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
osBiometricsNeedsSetup: () => Promise<boolean>;
/**
* Check whether biometrics can be automatically setup, or requires user interaction.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
osBiometricsCanAutoSetup: () => Promise<boolean>;
/**
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
*/
osBiometricsSetup: () => Promise<void>;
authenticateBiometric(): Promise<boolean>;
getBiometricKey(
service: string,

View File

@@ -28,6 +28,8 @@ export class BiometricsService implements BiometricsServiceAbstraction {
this.loadWindowsHelloService();
} else if (platform === "darwin") {
this.loadMacOSService();
} else if (platform === "linux") {
this.loadUnixService();
} else {
this.loadNoopBiometricsService();
}
@@ -49,6 +51,12 @@ export class BiometricsService implements BiometricsServiceAbstraction {
this.platformSpecificService = new BiometricDarwinMain(this.i18nService);
}
private loadUnixService() {
// eslint-disable-next-line
const BiometricUnixMain = require("./biometric.unix.main").default;
this.platformSpecificService = new BiometricUnixMain(this.i18nService, this.windowMain);
}
private loadNoopBiometricsService() {
// eslint-disable-next-line
const NoopBiometricsService = require("./biometric.noop.main").default;
@@ -59,6 +67,18 @@ export class BiometricsService implements BiometricsServiceAbstraction {
return await this.platformSpecificService.osSupportsBiometric();
}
async osBiometricsNeedsSetup() {
return await this.platformSpecificService.osBiometricsNeedsSetup();
}
async osBiometricsCanAutoSetup() {
return await this.platformSpecificService.osBiometricsCanAutoSetup();
}
async osBiometricsSetup() {
await this.platformSpecificService.osBiometricsSetup();
}
async canAuthBiometric({
service,
key,

View File

@@ -79,6 +79,15 @@ export class DesktopCredentialStorageListener {
case BiometricAction.OsSupported:
val = await this.biometricService.osSupportsBiometric();
break;
case BiometricAction.NeedsSetup:
val = await this.biometricService.osBiometricsNeedsSetup();
break;
case BiometricAction.Setup:
await this.biometricService.osBiometricsSetup();
break;
case BiometricAction.CanAutoSetup:
val = await this.biometricService.osBiometricsCanAutoSetup();
break;
default:
}

View File

@@ -11,7 +11,7 @@ import {
UnencryptedMessageResponse,
} from "../models/native-messaging";
import { BiometricMessage, BiometricAction } from "../types/biometric-message";
import { isDev, isMacAppStore, isWindowsStore } from "../utils";
import { isDev, isFlatpak, isMacAppStore, isSnapStore, isWindowsStore } from "../utils";
import { ClipboardWriteMessage } from "./types/clipboard";
@@ -48,6 +48,18 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.OsSupported,
} satisfies BiometricMessage),
biometricsNeedsSetup: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.NeedsSetup,
} satisfies BiometricMessage),
biometricsSetup: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Setup,
} satisfies BiometricMessage),
biometricsCanAutoSetup: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.CanAutoSetup,
} satisfies BiometricMessage),
authenticate: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Authenticate,
@@ -115,6 +127,8 @@ export default {
isDev: isDev(),
isMacAppStore: isMacAppStore(),
isWindowsStore: isWindowsStore(),
isFlatpak: isFlatpak(),
isSnapStore: isSnapStore(),
reloadProcess: () => ipcRenderer.send("reload-process"),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),

View File

@@ -135,6 +135,18 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
return await ipc.platform.biometric.osSupported();
}
async biometricsNeedsSetup(): Promise<boolean> {
return await ipc.platform.biometric.biometricsNeedsSetup();
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return await ipc.platform.biometric.biometricsCanAutoSetup();
}
async biometricsSetup(): Promise<void> {
return await ipc.platform.biometric.biometricsSetup();
}
/** This method is used to authenticate the user presence _only_.
* It should not be used in the process to retrieve
* biometric keys, which has a separate authentication mechanism.

View File

@@ -2,6 +2,9 @@ export enum BiometricAction {
EnabledForUser = "enabled",
OsSupported = "osSupported",
Authenticate = "authenticate",
NeedsSetup = "needsSetup",
Setup = "setup",
CanAutoSetup = "canAutoSetup",
}
export type BiometricMessage = {

View File

@@ -62,6 +62,10 @@ export function isWindowsStore() {
return windows && windowsStore === true;
}
export function isFlatpak() {
return process.platform === "linux" && process.env.container != null;
}
export function isWindowsPortable() {
return isWindows() && process.env.PORTABLE_EXECUTABLE_DIR != null;
}