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

[PM-26570] Remove biometrics v1 (#17629)

* Remove biometrics v1

* Cargo fmt

* Fix windows build

* Apply prettier

* Remove proxy code

* Fix build

* Fix

* Fix tests

* Remove v2 flag
This commit is contained in:
Bernd Schoolmann
2025-12-01 10:16:36 +01:00
committed by GitHub
parent b248341d0e
commit b7287d4614
21 changed files with 14 additions and 1066 deletions

View File

@@ -3,16 +3,12 @@ use anyhow::{anyhow, Result};
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
mod biometric;
pub use biometric::Biometric;
#[cfg(target_os = "windows")]
pub mod windows_focus;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
pub use biometric::Biometric;
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};

View File

@@ -2,7 +2,7 @@ use anyhow::{bail, Result};
use crate::biometric::{KeyMaterial, OsDerivedKey};
/// The MacOS implementation of the biometric trait.
/// Unimplemented stub for unsupported platforms
pub struct Biometric {}
impl super::BiometricTrait for Biometric {

View File

@@ -1,240 +0,0 @@
use std::{ffi::c_void, str::FromStr};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, HSTRING},
Security::Credentials::UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
Win32::{
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
UI::WindowsAndMessaging::GetForegroundWindow,
},
};
use windows_future::IAsyncOperation;
use super::{decrypt, encrypt, windows_focus::set_focus};
use crate::{
biometric::{KeyMaterial, OsDerivedKey},
crypto::CipherString,
};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
impl super::BiometricTrait for Biometric {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
let h = h as *mut c_void;
let window = HWND(h);
// The Windows Hello prompt is displayed inside the application window. For best result we
// should set the window to the foreground and focus it.
set_focus(window);
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
// unlock will not work. We get the current foreground window, which will either be the
// Bitwarden desktop app or the browser extension.
let foreground_window = unsafe { GetForegroundWindow() };
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
let result = operation.get()?;
match result {
UserConsentVerificationResult::Verified => Ok(true),
_ => Ok(false),
}
}
async fn available() -> Result<bool> {
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
match ucv_available {
UserConsentVerifierAvailability::Available => Ok(true),
// TODO: look into removing this and making the check more ad-hoc
UserConsentVerifierAvailability::DeviceBusy => Ok(true),
_ => Ok(false),
}
}
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
let challenge: [u8; 16] = match challenge_str {
Some(challenge_str) => base64_engine
.decode(challenge_str)?
.try_into()
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
None => random_challenge(),
};
// Uses a key derived from the iv. This key is not intended to add any security
// but only a place-holder
let key = Sha256::digest(challenge);
let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
}
async fn set_biometric_secret(
service: &str,
account: &str,
secret: &str,
key_material: Option<KeyMaterial>,
iv_b64: &str,
) -> Result<String> {
let key_material = key_material.ok_or(anyhow!(
"Key material is required for Windows Hello protected keys"
))?;
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
crate::password::set_password(service, account, &encrypted_secret).await?;
Ok(encrypted_secret)
}
async fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,
) -> Result<String> {
let key_material = key_material.ok_or(anyhow!(
"Key material is required for Windows Hello protected keys"
))?;
let encrypted_secret = crate::password::get_password(service, account).await?;
match CipherString::from_str(&encrypted_secret) {
Ok(secret) => {
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
let secret = decrypt(&secret, &key_material)?;
Ok(secret)
}
Err(_) => {
// If the secret is not a CipherString, it is not encrypted and we can return it
// directly.
Ok(encrypted_secret)
}
}
}
}
fn random_challenge() -> [u8; 16] {
let mut challenge = [0u8; 16];
rand::rng().fill_bytes(&mut challenge);
challenge
}
#[cfg(test)]
mod tests {
use super::*;
use crate::biometric::BiometricTrait;
#[test]
fn test_derive_key_material() {
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap();
assert_eq!(key.len(), 32);
assert_eq!(result.iv_b64, iv_input)
}
#[test]
fn test_derive_key_material_no_iv() {
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap();
assert_eq!(key.len(), 32);
let iv = base64_engine.decode(result.iv_b64).unwrap();
assert_eq!(iv.len(), 16);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn test_prompt() {
<Biometric as BiometricTrait>::prompt(
vec![0, 0, 0, 0, 0, 0, 0, 0],
String::from("Hello from Rust"),
)
.await
.unwrap();
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn test_available() {
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Key material is required for Windows Hello protected keys"
);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_unencrypted_secret() {
let test = "test";
let secret = "password";
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, secret)
.await
.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test")
.await
.unwrap();
assert_eq!(result, secret);
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_encrypted_secret() {
let test = "test";
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
crate::password::set_password(test, test, &secret.to_string())
.await
.unwrap();
let result =
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
.await
.unwrap();
crate::password::delete_password("test", "test")
.await
.unwrap();
assert_eq!(result, "secret");
}
#[tokio::test]
async fn set_biometric_secret_requires_key() {
let result =
<Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "").await;
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Key material is required for Windows Hello protected keys"
);
}
}

View File

@@ -1,28 +0,0 @@
use windows::{
core::s,
Win32::{
Foundation::HWND,
UI::{
Input::KeyboardAndMouse::SetFocus,
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
},
},
};
/// Searches for a window that looks like a security prompt and set it as focused.
/// Only works when the process has permission to foreground, either by being in foreground
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
pub fn focus_security_prompt() {
let class_name = s!("Credential Dialog Xaml Host");
let hwnd = unsafe { FindWindowA(class_name, None) };
if let Ok(hwnd) = hwnd {
set_focus(hwnd);
}
}
pub(crate) fn set_focus(window: HWND) {
unsafe {
let _ = SetForegroundWindow(window);
let _ = SetFocus(Some(window));
}
}

View File

@@ -8,9 +8,6 @@ use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "macos")]
embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist");
@@ -64,9 +61,6 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
#[allow(clippy::unwrap_used)]
#[tokio::main(flavor = "current_thread")]
async fn main() {
#[cfg(target_os = "windows")]
let should_foreground = windows::allow_foreground();
let sock_path = desktop_core::ipc::path("bw");
let log_path = {
@@ -158,9 +152,6 @@ async fn main() {
// Listen to stdin and send messages to ipc processor.
msg = stdin.next() => {
#[cfg(target_os = "windows")]
should_foreground.store(true, std::sync::atomic::Ordering::Relaxed);
match msg {
Some(Ok(msg)) => {
let msg = String::from_utf8(msg.to_vec()).unwrap();

View File

@@ -1,23 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn allow_foreground() -> Arc<AtomicBool> {
let should_foreground = Arc::new(AtomicBool::new(false));
let should_foreground_clone = should_foreground.clone();
let _ = std::thread::spawn(move || loop {
if !should_foreground_clone.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
should_foreground_clone.store(false, Ordering::Relaxed);
for _ in 0..60 {
desktop_core::biometric::windows_focus::focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(1000));
}
});
should_foreground
}

View File

@@ -101,8 +101,7 @@
supportsBiometric &&
form.value.biometric &&
isWindows &&
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
isWindowsV2BiometricsEnabled
(userHasMasterPassword || (form.value.pin && userHasPinSet))
"
>
<div class="checkbox form-group-child">

View File

@@ -302,7 +302,6 @@ describe("SettingsComponent", () => {
describe("windows desktop", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
@@ -449,7 +448,6 @@ describe("SettingsComponent", () => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart = true;
component.userHasMasterPassword = false;
@@ -558,7 +556,6 @@ describe("SettingsComponent", () => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart =
requireMasterPasswordOnAppRestart;
@@ -659,6 +656,7 @@ describe("SettingsComponent", () => {
describe("windows test cases", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
component.isWindows = true;
component.isLinux = false;
@@ -683,8 +681,6 @@ describe("SettingsComponent", () => {
describe("when windows v2 biometrics is enabled", () => {
beforeEach(() => {
component.isWindowsV2BiometricsEnabled = true;
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});

View File

@@ -148,7 +148,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
userHasPinSet: boolean;
pinEnabled$: Observable<boolean> = of(true);
isWindowsV2BiometricsEnabled: boolean = false;
consolidatedSessionTimeoutComponent$: Observable<boolean>;
@@ -297,8 +296,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// Autotype is for Windows initially
@@ -621,7 +618,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
// On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled.
if (
this.isWindows &&
this.isWindowsV2BiometricsEnabled &&
this.supportsBiometric &&
this.form.value.requireMasterPasswordOnAppRestart &&
this.form.value.biometric &&
@@ -682,14 +678,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false);
if (this.isWindowsV2BiometricsEnabled) {
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
if (!this.userHasMasterPassword && !this.userHasPinSet) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(activeUserId);
} else {
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
}
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
if (!this.userHasMasterPassword && !this.userHasPinSet) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(activeUserId);
} else {
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
}
} else if (this.isLinux) {
// Similar to Windows

View File

@@ -16,9 +16,6 @@ export abstract class DesktopBiometricsService extends BiometricsService {
abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
abstract hasPersistentKey(userId: UserId): Promise<boolean>;
/* 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

@@ -58,10 +58,6 @@ export class MainBiometricsIPCListener {
message.userId as UserId,
SymmetricCryptoKey.fromString(message.key as string),
);
case BiometricAction.EnableWindowsV2:
return await this.biometricService.enableWindowsV2Biometrics();
case BiometricAction.IsWindowsV2Enabled:
return await this.biometricService.isWindowsV2BiometricsEnabled();
case BiometricAction.EnableLinuxV2:
return await this.biometricService.enableLinuxV2Biometrics();
case BiometricAction.IsLinuxV2Enabled:

View File

@@ -20,7 +20,6 @@ import { MainBiometricsService } from "./main-biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
import { OsBiometricService } from "./os-biometrics.service";
jest.mock("@bitwarden/desktop-napi", () => {
@@ -61,7 +60,7 @@ describe("MainBiometricsService", function () {
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
});
it("Should create a biometrics service specific for MacOs", () => {
@@ -289,78 +288,6 @@ describe("MainBiometricsService", function () {
});
});
describe("enableWindowsV2Biometrics", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"win32",
biometricStateService,
encryptService,
cryptoFunctionService,
);
await sut.enableWindowsV2Biometrics();
expect(logService.info).toHaveBeenCalledWith(
"[BiometricsMain] Loading native biometrics module v2 for windows",
);
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
});
it("should not enable Windows V2 biometrics when platform is not win32", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"darwin",
biometricStateService,
encryptService,
cryptoFunctionService,
);
await sut.enableWindowsV2Biometrics();
expect(logService.info).not.toHaveBeenCalled();
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false);
});
it("should not enable Windows V2 biometrics when already enabled", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"win32",
biometricStateService,
encryptService,
cryptoFunctionService,
);
// Enable it first
await sut.enableWindowsV2Biometrics();
// Enable it again
await sut.enableWindowsV2Biometrics();
expect(logService.info).toHaveBeenCalledWith(
"[BiometricsMain] Loading native biometrics module v2 for windows",
);
expect(logService.info).toHaveBeenCalledTimes(1);
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
});
});
describe("pass through methods that call platform specific osBiometricsService methods", () => {
const userId = newGuid() as UserId;
let sut: MainBiometricsService;

View File

@@ -16,7 +16,6 @@ import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private shouldAutoPrompt = true;
private windowsV2BiometricsEnabled = false;
private linuxV2BiometricsEnabled = false;
constructor(
@@ -30,15 +29,10 @@ export class MainBiometricsService extends DesktopBiometricsService {
) {
super();
if (platform === "win32") {
// eslint-disable-next-line
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
this.osBiometricsService = new OsBiometricsServiceWindows(
this.osBiometricsService = new WindowsBiometricsSystem(
this.i18nService,
this.windowMain,
this.logService,
this.biometricStateService,
this.encryptService,
this.cryptoFunctionService,
);
} else if (platform === "darwin") {
// eslint-disable-next-line
@@ -156,22 +150,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
return await this.osBiometricsService.hasPersistentKey(userId);
}
async enableWindowsV2Biometrics(): Promise<void> {
if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) {
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows");
this.osBiometricsService = new WindowsBiometricsSystem(
this.i18nService,
this.windowMain,
this.logService,
);
this.windowsV2BiometricsEnabled = true;
}
}
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");

View File

@@ -1,378 +0,0 @@
import { randomBytes } from "node:crypto";
import { BrowserWindow } from "electron";
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
import OsDerivedKey = biometrics.OsDerivedKey;
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: {
available: jest.fn().mockResolvedValue(true),
getBiometricSecret: jest.fn().mockResolvedValue(""),
setBiometricSecret: jest.fn().mockResolvedValue(""),
deleteBiometricSecret: jest.fn(),
deriveKeyMaterial: jest.fn().mockResolvedValue({
keyB64: "",
ivB64: "",
}),
prompt: jest.fn().mockResolvedValue(true),
},
passwords: {
getPassword: jest.fn().mockResolvedValue(null),
deletePassword: jest.fn().mockImplementation(() => {}),
isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found",
},
};
});
describe("OsBiometricsServiceWindows", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const browserWindow = mock<BrowserWindow>();
const encryptionService: EncryptService = mock<EncryptService>();
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
const logService = mock<LogService>();
let service: OsBiometricsServiceWindows;
const key = new SymmetricCryptoKey(new Uint8Array(64));
const userId = "test-user-id" as UserId;
const serviceKey = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
beforeEach(() => {
windowMain.win = browserWindow;
service = new OsBiometricsServiceWindows(
i18nService,
windowMain,
logService,
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getBiometricsFirstUnlockStatusForUser", () => {
const userId = "test-user-id" as UserId;
it("should return Available when client key half is set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return UnlockNeeded when client key half is not set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.UnlockNeeded);
});
});
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
it("should return cached key half if already present", async () => {
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBe(cachedKeyHalf);
});
it("should decrypt and return existing encrypted client key half", async () => {
biometricStateService.getEncryptedClientKeyHalf = jest
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3]));
const decrypted = new Uint8Array([4, 5, 6]);
encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId);
expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key);
expect(result).toEqual(decrypted);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted);
});
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
const randomBytes = new Uint8Array([7, 8, 9]);
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
const encrypted = new Uint8Array([10, 11, 12]);
encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted);
biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32);
expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key);
expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith(
encrypted,
userId,
);
expect(result).toEqual(randomBytes);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes);
});
});
describe("supportsBiometrics", () => {
it("should return true if biometrics are available", async () => {
biometrics.available = jest.fn().mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should return false if biometrics are not available", async () => {
biometrics.available = jest.fn().mockResolvedValue(false);
const result = await service.supportsBiometrics();
expect(result).toBe(false);
});
});
describe("getBiometricKey", () => {
beforeEach(() => {
biometrics.prompt = jest.fn().mockResolvedValue(true);
});
it("should return null when unsuccessfully authenticated biometrics", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
});
it.each([null, undefined, ""])(
"should throw error when no biometric key is found '%s'",
async (password) => {
passwords.getPassword = jest.fn().mockResolvedValue(password);
await expect(service.getBiometricKey(userId)).rejects.toThrow(
"Biometric key not found for user",
);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
},
);
it.each([[false], [true]])(
"should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
passwords.getPassword = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).toHaveBeenCalledWith(
serviceKey,
storageKey,
biometricKey,
{
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
},
"testIvB64",
);
},
);
it.each([[false], [true]])(
"should return the biometricKey if password is encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
const biometricKeyEncrypted = "2.testId|data|mac";
passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted);
biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).not.toHaveBeenCalled();
expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, {
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
});
},
);
});
describe("deleteBiometricKey", () => {
const serviceName = "Bitwarden_biometric";
const keyName = "test-user-id_user_biometric";
it("should delete biometric key successfully", async () => {
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
if (!keyFound) {
passwords.deletePassword = jest
.fn()
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
}
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
if (!keyFound) {
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.",
keyName,
serviceName,
);
}
});
it("should throw error when deletePassword for key throws unexpected errors", async () => {
const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockRejectedValue(error);
await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
});
});
describe("authenticateBiometric", () => {
const hwnd = randomBytes(32).buffer;
const consentMessage = "Test Windows Hello Consent Message";
beforeEach(() => {
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
i18nService.t.mockReturnValue(consentMessage);
});
it("should return true when biometric authentication is successful", async () => {
const result = await service.authenticateBiometric();
expect(result).toBe(true);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
it("should return false when biometric authentication fails", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.authenticateBiometric();
expect(result).toBe(false);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
});
describe("getStorageDetails", () => {
it.each([
["testClientKeyHalfB64", "testIvB64"],
[undefined, "testIvB64"],
["testClientKeyHalfB64", null],
[undefined, null],
])(
"should derive key material and ivB64 and return it when os key half not saved yet",
async (clientKeyHalfB64, ivB64) => {
service["setIv"](ivB64);
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: "derivedIvB64",
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
const result = await service["getStorageDetails"]({ clientKeyHalfB64 });
expect(result).toEqual({
key_material: {
osKeyPartB64: derivedKeyMaterial.keyB64,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: derivedKeyMaterial.ivB64,
});
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64);
expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64);
expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64);
},
);
it("should throw an error when deriving key material and returned iv is null", async () => {
service["setIv"]("testIvB64");
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: null as string | undefined | null,
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
await expect(
service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }),
).rejects.toThrow("Initialization Vector is null");
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64");
});
});
describe("setIv", () => {
it("should set the iv and reset the osKeyHalf", () => {
const iv = "testIv";
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](iv);
expect(service["_iv"]).toBe(iv);
expect(service["_osKeyHalf"]).toBeNull();
});
it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => {
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](undefined);
expect(service["_iv"]).toBeNull();
expect(service["_osKeyHalf"]).toBeNull();
});
});
});

View File

@@ -1,214 +0,0 @@
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import { OsBiometricService } from "./os-biometrics.service";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
export default class OsBiometricsServiceWindows implements OsBiometricService {
// Use set helper method instead of direct access
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array>();
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
async supportsBiometrics(): Promise<boolean> {
return await biometrics.available();
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const success = await this.authenticateBiometric();
if (!success) {
return null;
}
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (value == null || value == "") {
throw new Error("Biometric key not found for user");
}
let clientKeyHalfB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
}
if (!EncString.isSerializedEncString(value)) {
// Update to format encrypted with client key half
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
});
await biometrics.setBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
value,
storageDetails.key_material,
storageDetails.ivB64,
);
return SymmetricCryptoKey.fromString(value);
} else {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
});
return SymmetricCryptoKey.fromString(
await biometrics.getBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
storageDetails.key_material,
),
);
}
}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
});
await biometrics.setBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
key.toBase64(),
storageDetails.key_material,
storageDetails.ivB64,
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric key %s not found for service %s.",
getLookupKeyForUser(userId),
SERVICE,
);
} else {
throw e;
}
}
}
/**
* Prompts Windows Hello
*/
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
}
private async getStorageDetails({
clientKeyHalfB64,
}: {
clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
if (this._iv == null) {
throw new Error("Initialization Vector is null");
}
const result = {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
// napi-rs fails to convert null values
if (result.key_material.clientKeyPartB64 == null) {
delete result.key_material.clientKeyPartB64;
}
return result;
}
// 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 ?? null;
this._osKeyHalf = null;
}
async needsSetup() {
return false;
}
async canAutoSetup(): Promise<boolean> {
return false;
}
async runSetup(): Promise<void> {}
async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<Uint8Array> {
if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId)!;
}
// Retrieve existing key half if it exists
let clientKeyHalf: Uint8Array | null = null;
const encryptedClientKeyHalf =
await this.biometricStateService.getEncryptedClientKeyHalf(userId);
if (encryptedClientKeyHalf != null) {
clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key);
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
this.clientKeyHalves.set(userId, clientKeyHalf);
return clientKeyHalf;
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
if (this.clientKeyHalves.has(userId)) {
return BiometricsStatus.Available;
} else {
return BiometricsStatus.UnlockNeeded;
}
}
}

View File

@@ -77,14 +77,6 @@ export class RendererBiometricsService extends DesktopBiometricsService {
return await ipc.keyManagement.biometric.hasPersistentKey(userId);
}
async enableWindowsV2Biometrics(): Promise<void> {
return await ipc.keyManagement.biometric.enableWindowsV2Biometrics();
}
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
}
async enableLinuxV2Biometrics(): Promise<void> {
return await ipc.keyManagement.biometric.enableLinuxV2Biometrics();
}

View File

@@ -61,14 +61,6 @@ const biometric = {
action: BiometricAction.HasPersistentKey,
userId: userId,
} satisfies BiometricMessage),
enableWindowsV2Biometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnableWindowsV2,
} satisfies BiometricMessage),
isWindowsV2BiometricsEnabled: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsWindowsV2Enabled,
} satisfies BiometricMessage),
enableLinuxV2Biometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnableLinuxV2,

View File

@@ -503,19 +503,4 @@ describe("BiometricMessageHandlerService", () => {
},
);
});
describe("init", () => {
it("enables Windows v2 biometrics when feature flag enabled", async () => {
configService.getFeatureFlag.mockReturnValue(true);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled();
});
it("does not enable Windows v2 biometrics when feature flag disabled", async () => {
configService.getFeatureFlag.mockReturnValue(false);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled();
});
});
});

View File

@@ -119,13 +119,6 @@ export class BiometricMessageHandlerService {
"[BiometricMessageHandlerService] Initializing biometric message handler",
);
const windowsV2Enabled = await this.configService.getFeatureFlag(
FeatureFlag.WindowsBiometricsV2,
);
if (windowsV2Enabled) {
await this.biometricsService.enableWindowsV2Biometrics();
}
const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2);
if (linuxV2Enabled) {
await this.biometricsService.enableLinuxV2Biometrics();

View File

@@ -17,9 +17,6 @@ export enum BiometricAction {
EnrollPersistent = "enrollPersistent",
HasPersistentKey = "hasPersistentKey",
EnableWindowsV2 = "enableWindowsV2",
IsWindowsV2Enabled = "isWindowsV2Enabled",
EnableLinuxV2 = "enableLinuxV2",
IsLinuxV2Enabled = "isLinuxV2Enabled",
}