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:
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,9 +17,6 @@ export enum BiometricAction {
|
||||
EnrollPersistent = "enrollPersistent",
|
||||
HasPersistentKey = "hasPersistentKey",
|
||||
|
||||
EnableWindowsV2 = "enableWindowsV2",
|
||||
IsWindowsV2Enabled = "isWindowsV2Enabled",
|
||||
|
||||
EnableLinuxV2 = "enableLinuxV2",
|
||||
IsLinuxV2Enabled = "isLinuxV2Enabled",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user