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

[PM-18054] Chrome extension biometric unlock not functioning correctly with Windows Hello. (#14953)

* Chrome extension biometric unlock not functioning correctly with Windows Hello.

When unlocking via Windows Hello prompt, the popup have to be in the foreground. If it is not, even for short amount of time (few seconds), if later prompt confirmed, it won't return success when returning signed os key half.

* unit test coverage

* unit test coverage

* exclude test files from build

* use electron `setAlwaysOnTop` instead of toggle

* remove Windows os key half created with derive_key_material biometric function, that prompted Windows Hello.

Moves Windows hello prompt into getBiometricKey.
Witness key no longer needed.

* windows crate formatting

* remove biometric on app start for windows

* failing os biometrics windows unit tests

* cleanup of os biometrics windows unit tests

* increased coverage of os biometrics windows unit tests

* open Windows Hello prompt in the currently focused window, instead of always desktop app

* conflict resolution after merge, typescript lint issues, increased test coverage.

* backwards compatibility when require password on start was disabled

* biometric unlock cancellation and error handling

* biometric settings simplifications
This commit is contained in:
Maciej Zieniuk
2025-07-21 19:35:31 +02:00
committed by GitHub
parent 8b5e6adc37
commit 167fa9a7ab
11 changed files with 481 additions and 603 deletions

View File

@@ -83,3 +83,93 @@ impl KeyMaterial {
Ok(Sha256::digest(self.digest_material())) Ok(Sha256::digest(self.digest_material()))
} }
} }
#[cfg(test)]
mod tests {
use crate::biometric::{decrypt, encrypt, KeyMaterial};
use crate::crypto::CipherString;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use std::str::FromStr;
fn key_material() -> KeyMaterial {
KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
}
}
#[test]
fn test_encrypt() {
let key_material = key_material();
let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned();
let secret = encrypt("secret", &key_material, &iv_b64)
.unwrap()
.parse::<CipherString>()
.unwrap();
match secret {
CipherString::AesCbc256_B64 { iv, data: _ } => {
assert_eq!(iv_b64, base64_engine.encode(iv));
}
_ => panic!("Invalid cipher string"),
}
}
#[test]
fn test_decrypt() {
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
let key_material = key_material();
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
}
#[test]
fn key_material_produces_valid_key() {
let result = key_material().derive_key().unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn key_material_uses_os_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_uses_client_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.client_key_part_b64 =
Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned());
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_produces_consistent_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
assert_eq!(
result,
[
81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218,
237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246
]
.into()
);
}
#[test]
fn key_material_produces_unique_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
}

View File

@@ -1,22 +1,18 @@
use std::{ use std::{ffi::c_void, str::FromStr};
ffi::c_void,
str::FromStr,
sync::{atomic::AtomicBool, Arc},
};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore; use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use windows::{ use windows::{
core::{factory, h, HSTRING}, core::{factory, HSTRING},
Security::{ Security::Credentials::UI::{
Credentials::{ UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, UI::*,
}, },
Cryptography::CryptographicBuffer, Win32::{
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
UI::WindowsAndMessaging::GetForegroundWindow,
}, },
Win32::{Foundation::HWND, System::WinRT::IUserConsentVerifierInterop},
}; };
use windows_future::IAsyncOperation; use windows_future::IAsyncOperation;
@@ -25,10 +21,7 @@ use crate::{
crypto::CipherString, crypto::CipherString,
}; };
use super::{ use super::{decrypt, encrypt, windows_focus::set_focus};
decrypt, encrypt,
windows_focus::{focus_security_prompt, set_focus},
};
/// The Windows OS implementation of the biometric trait. /// The Windows OS implementation of the biometric trait.
pub struct Biometric {} pub struct Biometric {}
@@ -44,9 +37,15 @@ impl super::BiometricTrait for Biometric {
// should set the window to the foreground and focus it. // should set the window to the foreground and focus it.
set_focus(window); 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 interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
let operation: IAsyncOperation<UserConsentVerificationResult> = let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
unsafe { interop.RequestVerificationForWindowAsync(window, &HSTRING::from(message))? }; interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
let result = operation.get()?; let result = operation.get()?;
match result { match result {
@@ -65,14 +64,6 @@ impl super::BiometricTrait for Biometric {
} }
} }
/// Derive the symmetric encryption key from the Windows Hello signature.
///
/// This works by signing a static challenge string with Windows Hello protected key store. The
/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the
/// Windows Hello protected keys.
///
/// Windows will only sign the challenge if the user has successfully authenticated with Windows,
/// ensuring user presence.
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> { fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
let challenge: [u8; 16] = match challenge_str { let challenge: [u8; 16] = match challenge_str {
Some(challenge_str) => base64_engine Some(challenge_str) => base64_engine
@@ -81,51 +72,10 @@ impl super::BiometricTrait for Biometric {
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
None => random_challenge(), None => random_challenge(),
}; };
let bitwarden = h!("Bitwarden");
let result = KeyCredentialManager::RequestCreateAsync( // Uses a key derived from the iv. This key is not intended to add any security
bitwarden, // but only a place-holder
KeyCredentialCreationOption::FailIfExists, let key = Sha256::digest(challenge);
)?
.get()?;
let result = match result.Status()? {
KeyCredentialStatus::CredentialAlreadyExists => {
KeyCredentialManager::OpenAsync(bitwarden)?.get()?
}
KeyCredentialStatus::Success => result,
_ => return Err(anyhow!("Failed to create key credential")),
};
let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?;
let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?;
focus_security_prompt();
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let _ = std::thread::spawn(move || loop {
if !done_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
let signature = async_operation.get();
done.store(true, std::sync::atomic::Ordering::Relaxed);
let signature = signature?;
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
}
let signature_buffer = signature.Result()?;
let mut signature_value =
windows::core::Array::<u8>::with_len(signature_buffer.Length().unwrap() as usize);
CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?;
let key = Sha256::digest(&*signature_value);
let key_b64 = base64_engine.encode(key); let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge); let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 }) Ok(OsDerivedKey { key_b64, iv_b64 })
@@ -182,10 +132,9 @@ fn random_challenge() -> [u8; 16] {
mod tests { mod tests {
use super::*; use super::*;
use crate::biometric::{encrypt, BiometricTrait}; use crate::biometric::BiometricTrait;
#[test] #[test]
#[cfg(feature = "manual_test")]
fn test_derive_key_material() { fn test_derive_key_material() {
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w=="; let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap(); let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
@@ -195,7 +144,6 @@ mod tests {
} }
#[test] #[test]
#[cfg(feature = "manual_test")]
fn test_derive_key_material_no_iv() { fn test_derive_key_material_no_iv() {
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap(); let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap(); let key = base64_engine.decode(result.key_b64).unwrap();
@@ -221,38 +169,8 @@ mod tests {
assert!(<Biometric as BiometricTrait>::available().await.unwrap()) assert!(<Biometric as BiometricTrait>::available().await.unwrap())
} }
#[test]
fn test_encrypt() {
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned();
let secret = encrypt("secret", &key_material, &iv_b64)
.unwrap()
.parse::<CipherString>()
.unwrap();
match secret {
CipherString::AesCbc256_B64 { iv, data: _ } => {
assert_eq!(iv_b64, base64_engine.encode(iv));
}
_ => panic!("Invalid cipher string"),
}
}
#[test]
fn test_decrypt() {
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()),
};
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
}
#[tokio::test] #[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_requires_key() { async fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await; let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
assert!(result.is_err()); assert!(result.is_err());
@@ -263,6 +181,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_unencrypted_secret() { async fn get_biometric_secret_handles_unencrypted_secret() {
let test = "test"; let test = "test";
let secret = "password"; let secret = "password";
@@ -284,6 +203,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_encrypted_secret() { async fn get_biometric_secret_handles_encrypted_secret() {
let test = "test"; let test = "test";
let secret = let secret =
@@ -316,61 +236,4 @@ mod tests {
"Key material is required for Windows Hello protected keys" "Key material is required for Windows Hello protected keys"
); );
} }
fn key_material() -> KeyMaterial {
KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
}
}
#[test]
fn key_material_produces_valid_key() {
let result = key_material().derive_key().unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn key_material_uses_os_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_uses_client_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.client_key_part_b64 =
Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned());
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_produces_consistent_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
assert_eq!(
result,
[
81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218,
237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246
]
.into()
);
}
#[test]
fn key_material_produces_unique_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
} }

View File

@@ -126,13 +126,13 @@
{{ biometricText | i18n }} {{ biometricText | i18n }}
</label> </label>
</div> </div>
<small class="help-block" *ngIf="this.form.value.biometric && !this.isLinux">{{ <small class="help-block" *ngIf="this.form.value.biometric && this.isMac">{{
additionalBiometricSettingsText | i18n "additionalTouchIdSettings" | i18n
}}</small> }}</small>
</div> </div>
<div <div
class="form-group" class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && !this.isLinux" *ngIf="supportsBiometric && this.form.value.biometric && this.isMac"
> >
<div class="checkbox form-group-child"> <div class="checkbox form-group-child">
<label for="autoPromptBiometrics"> <label for="autoPromptBiometrics">
@@ -142,7 +142,7 @@
formControlName="autoPromptBiometrics" formControlName="autoPromptBiometrics"
(change)="updateAutoPromptBiometrics()" (change)="updateAutoPromptBiometrics()"
/> />
{{ autoPromptBiometricsText | i18n }} {{ "autoPromptTouchId" | i18n }}
</label> </label>
</div> </div>
</div> </div>
@@ -152,7 +152,7 @@
supportsBiometric && supportsBiometric &&
this.form.value.biometric && this.form.value.biometric &&
(userHasMasterPassword || (this.form.value.pin && userHasPinSet)) && (userHasMasterPassword || (this.form.value.pin && userHasPinSet)) &&
this.isWindows false
" "
> >
<div class="checkbox form-group-child"> <div class="checkbox form-group-child">
@@ -170,9 +170,6 @@
} }
</label> </label>
</div> </div>
<small class="help-block form-group-child" *ngIf="isWindows">{{
"recommendedForSecurity" | i18n
}}</small>
</div> </div>
</ng-container> </ng-container>
</div> </div>

View File

@@ -271,20 +271,20 @@ describe("SettingsComponent", () => {
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true); vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
}); });
it("require password or pin on app start message when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { describe("windows desktop", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
});
it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
const policy = new Policy(); const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin; policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false; policy.enabled = false;
policyService.policiesByType$.mockReturnValue(of([policy])); policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true); pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit(); await component.ngOnInit();
@@ -293,33 +293,14 @@ describe("SettingsComponent", () => {
const requirePasswordOnStartLabelElement = fixture.debugElement.query( const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"), By.css("label[for='requirePasswordOnStart']"),
); );
expect(requirePasswordOnStartLabelElement).not.toBeNull(); expect(requirePasswordOnStartLabelElement).toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
});
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password or pin on app start");
}); });
it("require password on app start message when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy(); const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin; policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true; policy.enabled = true;
policyService.policiesByType$.mockReturnValue(of([policy])); policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true); pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit(); await component.ngOnInit();
@@ -328,17 +309,8 @@ describe("SettingsComponent", () => {
const requirePasswordOnStartLabelElement = fixture.debugElement.query( const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"), By.css("label[for='requirePasswordOnStart']"),
); );
expect(requirePasswordOnStartLabelElement).not.toBeNull(); expect(requirePasswordOnStartLabelElement).toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
}); });
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password on app start");
}); });
}); });

View File

@@ -78,6 +78,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
showOpenAtLoginOption = false; showOpenAtLoginOption = false;
isWindows: boolean; isWindows: boolean;
isLinux: boolean; isLinux: boolean;
isMac: boolean;
enableTrayText: string; enableTrayText: string;
enableTrayDescText: string; enableTrayDescText: string;
@@ -170,31 +171,33 @@ export class SettingsComponent implements OnInit, OnDestroy {
private configService: ConfigService, private configService: ConfigService,
private validationService: ValidationService, private validationService: ValidationService,
) { ) {
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; this.isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.isLinux = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
// Workaround to avoid ghosting trays https://github.com/electron/electron/issues/17622 // Workaround to avoid ghosting trays https://github.com/electron/electron/issues/17622
this.requireEnableTray = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop; this.requireEnableTray = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
const trayKey = isMac ? "enableMenuBar" : "enableTray"; const trayKey = this.isMac ? "enableMenuBar" : "enableTray";
this.enableTrayText = this.i18nService.t(trayKey); this.enableTrayText = this.i18nService.t(trayKey);
this.enableTrayDescText = this.i18nService.t(trayKey + "Desc"); this.enableTrayDescText = this.i18nService.t(trayKey + "Desc");
const minToTrayKey = isMac ? "enableMinToMenuBar" : "enableMinToTray"; const minToTrayKey = this.isMac ? "enableMinToMenuBar" : "enableMinToTray";
this.enableMinToTrayText = this.i18nService.t(minToTrayKey); this.enableMinToTrayText = this.i18nService.t(minToTrayKey);
this.enableMinToTrayDescText = this.i18nService.t(minToTrayKey + "Desc"); this.enableMinToTrayDescText = this.i18nService.t(minToTrayKey + "Desc");
const closeToTrayKey = isMac ? "enableCloseToMenuBar" : "enableCloseToTray"; const closeToTrayKey = this.isMac ? "enableCloseToMenuBar" : "enableCloseToTray";
this.enableCloseToTrayText = this.i18nService.t(closeToTrayKey); this.enableCloseToTrayText = this.i18nService.t(closeToTrayKey);
this.enableCloseToTrayDescText = this.i18nService.t(closeToTrayKey + "Desc"); this.enableCloseToTrayDescText = this.i18nService.t(closeToTrayKey + "Desc");
const startToTrayKey = isMac ? "startToMenuBar" : "startToTray"; const startToTrayKey = this.isMac ? "startToMenuBar" : "startToTray";
this.startToTrayText = this.i18nService.t(startToTrayKey); this.startToTrayText = this.i18nService.t(startToTrayKey);
this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc"); this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc");
this.showOpenAtLoginOption = !ipc.platform.isWindowsStore; this.showOpenAtLoginOption = !ipc.platform.isWindowsStore;
// DuckDuckGo browser is only for macos initially // DuckDuckGo browser is only for macos initially
this.showDuckDuckGoIntegrationOption = isMac; this.showDuckDuckGoIntegrationOption = this.isMac;
const localeOptions: any[] = []; const localeOptions: any[] = [];
this.i18nService.supportedTranslationLocales.forEach((locale) => { this.i18nService.supportedTranslationLocales.forEach((locale) => {
@@ -239,7 +242,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
async ngOnInit() { async ngOnInit() {
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.isLinux = (await this.platformUtilsService.getDevice()) === DeviceType.LinuxDesktop;
// Autotype is for Windows initially // Autotype is for Windows initially
const isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop; const isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
@@ -250,8 +252,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword(); this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
this.currentUserEmail = activeAccount.email; this.currentUserEmail = activeAccount.email;
this.currentUserId = activeAccount.id; this.currentUserId = activeAccount.id;
@@ -911,28 +911,4 @@ export class SettingsComponent implements OnInit, OnDestroy {
throw new Error("Unsupported platform"); throw new Error("Unsupported platform");
} }
} }
get autoPromptBiometricsText() {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "autoPromptTouchId";
case DeviceType.WindowsDesktop:
return "autoPromptWindowsHello";
case DeviceType.LinuxDesktop:
return "autoPromptPolkit";
default:
throw new Error("Unsupported platform");
}
}
get additionalBiometricSettingsText() {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "additionalTouchIdSettings";
case DeviceType.WindowsDesktop:
return "additionalWindowsHelloSettings";
default:
throw new Error("Unsupported platform");
}
}
} }

View File

@@ -34,6 +34,7 @@ const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/"; const policyPath = "/usr/share/polkit-1/actions/";
const SERVICE = "Bitwarden_biometric"; const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string { function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`; return `${userId}_user_biometric`;
} }
@@ -45,16 +46,18 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
private cryptoFunctionService: CryptoFunctionService, private cryptoFunctionService: CryptoFunctionService,
private logService: LogService, private logService: LogService,
) {} ) {}
private _iv: string | null = null; private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access // Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null; private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array | null>(); private clientKeyHalves = new Map<UserId, Uint8Array | null>();
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> { async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyPartB64 = Utils.fromBufferToB64( const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key),
); const storageDetails = await this.getStorageDetails({
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); clientKeyHalfB64: clientKeyHalf ? Utils.fromBufferToB64(clientKeyHalf) : undefined,
});
await biometrics.setBiometricSecret( await biometrics.setBiometricSecret(
SERVICE, SERVICE,
getLookupKeyForUser(userId), getLookupKeyForUser(userId),
@@ -63,6 +66,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
storageDetails.ivB64, storageDetails.ivB64,
); );
} }
async deleteBiometricKey(userId: UserId): Promise<void> { async deleteBiometricKey(userId: UserId): Promise<void> {
try { try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
@@ -91,11 +95,15 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
if (value == null || value == "") { if (value == null || value == "") {
return null; return null;
} else { } else {
const clientKeyHalf = this.clientKeyHalves.get(userId); let clientKeyPartB64: string | null = null;
const clientKeyPartB64 = Utils.fromBufferToB64(clientKeyHalf); if (this.clientKeyHalves.has(userId)) {
clientKeyPartB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
}
const encValue = new EncString(value); const encValue = new EncString(value);
this.setIv(encValue.iv); this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyPartB64 ?? undefined,
});
const storedValue = await biometrics.getBiometricSecret( const storedValue = await biometrics.getBiometricSecret(
SERVICE, SERVICE,
getLookupKeyForUser(userId), getLookupKeyForUser(userId),
@@ -169,7 +177,6 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) { if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verification!
this._osKeyHalf = keyMaterial.keyB64; this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64; this._iv = keyMaterial.ivB64;
} }
@@ -209,8 +216,8 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
} }
if (clientKeyHalf == null) { if (clientKeyHalf == null) {
// Set a key half if it doesn't exist // Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32); clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key); const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
} }

View File

@@ -1,51 +1,65 @@
import { randomBytes } from "node:crypto";
import { BrowserWindow } from "electron";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { passwords } from "@bitwarden/desktop-napi"; import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main"; import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
jest.mock("@bitwarden/desktop-napi", () => ({ import OsDerivedKey = biometrics.OsDerivedKey;
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: { biometrics: {
available: jest.fn(), available: jest.fn().mockResolvedValue(true),
setBiometricSecret: jest.fn(), getBiometricSecret: jest.fn().mockResolvedValue(""),
getBiometricSecret: jest.fn(), setBiometricSecret: jest.fn().mockResolvedValue(""),
deleteBiometricSecret: jest.fn(), deleteBiometricSecret: jest.fn(),
prompt: jest.fn(), deriveKeyMaterial: jest.fn().mockResolvedValue({
deriveKeyMaterial: jest.fn(), keyB64: "",
ivB64: "",
}),
prompt: jest.fn().mockResolvedValue(true),
}, },
passwords: { passwords: {
getPassword: jest.fn(), getPassword: jest.fn().mockResolvedValue(null),
deletePassword: jest.fn(), deletePassword: jest.fn().mockImplementation(() => {}),
isAvailable: jest.fn(), isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found", 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>();
describe("OsBiometricsServiceWindows", () => {
let service: OsBiometricsServiceWindows; let service: OsBiometricsServiceWindows;
let i18nService: I18nService;
let windowMain: WindowMain;
let logService: LogService;
let biometricStateService: BiometricStateService;
const mockUserId = "test-user-id" as UserId; const key = new SymmetricCryptoKey(new Uint8Array(64));
const userId = "test-user-id" as UserId;
const serviceKey = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
beforeEach(() => { beforeEach(() => {
i18nService = mock<I18nService>(); windowMain.win = browserWindow;
windowMain = mock<WindowMain>();
logService = mock<LogService>();
biometricStateService = mock<BiometricStateService>();
const encryptionService = mock<EncryptService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
service = new OsBiometricsServiceWindows( service = new OsBiometricsServiceWindows(
i18nService, i18nService,
windowMain, windowMain,
@@ -62,20 +76,13 @@ describe("OsBiometricsServiceWindows", () => {
describe("getBiometricsFirstUnlockStatusForUser", () => { describe("getBiometricsFirstUnlockStatusForUser", () => {
const userId = "test-user-id" as UserId; const userId = "test-user-id" as UserId;
it("should return Available when requirePasswordOnRestart is false", async () => { it("should return Available when client key half is set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return Available when requirePasswordOnRestart is true and client key half is set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
(service as any).clientKeyHalves = new Map<string, Uint8Array>(); (service as any).clientKeyHalves = new Map<string, Uint8Array>();
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4])); (service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
const result = await service.getBiometricsFirstUnlockStatusForUser(userId); const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available); expect(result).toBe(BiometricsStatus.Available);
}); });
it("should return UnlockNeeded when requirePasswordOnRestart is true and client key half is not set", async () => { it("should return UnlockNeeded when client key half is not set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
(service as any).clientKeyHalves = new Map<string, Uint8Array>(); (service as any).clientKeyHalves = new Map<string, Uint8Array>();
const result = await service.getBiometricsFirstUnlockStatusForUser(userId); const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.UnlockNeeded); expect(result).toBe(BiometricsStatus.UnlockNeeded);
@@ -83,32 +90,7 @@ describe("OsBiometricsServiceWindows", () => {
}); });
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => { describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
const userId = "test-user-id" as UserId;
const key = new SymmetricCryptoKey(new Uint8Array(64));
let encryptionService: EncryptService;
let cryptoFunctionService: CryptoFunctionService;
beforeEach(() => {
encryptionService = mock<EncryptService>();
cryptoFunctionService = mock<CryptoFunctionService>();
service = new OsBiometricsServiceWindows(
mock<I18nService>(),
windowMain,
mock<LogService>(),
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
it("should return null if getRequirePasswordOnRestart is false", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBeNull();
});
it("should return cached key half if already present", async () => { it("should return cached key half if already present", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
const cachedKeyHalf = new Uint8Array([10, 20, 30]); const cachedKeyHalf = new Uint8Array([10, 20, 30]);
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf); (service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
@@ -116,7 +98,6 @@ describe("OsBiometricsServiceWindows", () => {
}); });
it("should decrypt and return existing encrypted client key half", async () => { it("should decrypt and return existing encrypted client key half", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest biometricStateService.getEncryptedClientKeyHalf = jest
.fn() .fn()
.mockResolvedValue(new Uint8Array([1, 2, 3])); .mockResolvedValue(new Uint8Array([1, 2, 3]));
@@ -132,7 +113,6 @@ describe("OsBiometricsServiceWindows", () => {
}); });
it("should generate, encrypt, store, and cache a new key half if none exists", async () => { it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null); biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
const randomBytes = new Uint8Array([7, 8, 9]); const randomBytes = new Uint8Array([7, 8, 9]);
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes); cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
@@ -148,50 +128,139 @@ describe("OsBiometricsServiceWindows", () => {
encrypted, encrypted,
userId, userId,
); );
expect(result).toBeNull(); expect(result).toEqual(randomBytes);
expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull(); 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", () => { describe("deleteBiometricKey", () => {
const serviceName = "Bitwarden_biometric"; const serviceName = "Bitwarden_biometric";
const keyName = "test-user-id_user_biometric"; const keyName = "test-user-id_user_biometric";
const witnessKeyName = "test-user-id_user_biometric_witness";
it("should delete biometric key successfully", async () => { it("should delete biometric key successfully", async () => {
await service.deleteBiometricKey(mockUserId); await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
}); });
it.each([ it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
[false, false],
[false, true],
[true, false],
])(
"should not throw error if key found: %s and witness key found: %s",
async (keyFound, witnessKeyFound) => {
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
if (account === keyName) {
if (!keyFound) { if (!keyFound) {
throw new Error(passwords.PASSWORD_NOT_FOUND); passwords.deletePassword = jest
.fn()
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
} }
return Promise.resolve();
}
if (account === witnessKeyName) {
if (!witnessKeyFound) {
throw new Error(passwords.PASSWORD_NOT_FOUND);
}
return Promise.resolve();
}
throw new Error("Unexpected key");
});
await service.deleteBiometricKey(mockUserId); await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
if (!keyFound) { if (!keyFound) {
expect(logService.debug).toHaveBeenCalledWith( expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.", "[OsBiometricService] Biometric key %s not found for service %s.",
@@ -199,50 +268,111 @@ describe("OsBiometricsServiceWindows", () => {
serviceName, serviceName,
); );
} }
if (!witnessKeyFound) { });
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric witness key %s not found for service %s.",
witnessKeyName,
serviceName,
);
}
},
);
it("should throw error when deletePassword for key throws unexpected errors", async () => { it("should throw error when deletePassword for key throws unexpected errors", async () => {
const error = new Error("Unexpected error"); const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockImplementation((_, account) => { passwords.deletePassword = jest.fn().mockRejectedValue(error);
if (account === keyName) {
throw error;
}
if (account === witnessKeyName) {
return Promise.resolve();
}
throw new Error("Unexpected key");
});
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).not.toHaveBeenCalledWith(serviceName, witnessKeyName); });
}); });
it("should throw error when deletePassword for witness key throws unexpected errors", async () => { describe("authenticateBiometric", () => {
const error = new Error("Unexpected error"); const hwnd = randomBytes(32).buffer;
passwords.deletePassword = jest.fn().mockImplementation((_, account) => { const consentMessage = "Test Windows Hello Consent Message";
if (account === keyName) {
return Promise.resolve(); beforeEach(() => {
} windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
if (account === witnessKeyName) { i18nService.t.mockReturnValue(consentMessage);
throw error;
}
throw new Error("Unexpected key");
}); });
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); it("should return true when biometric authentication is successful", async () => {
const result = await service.authenticateBiometric();
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); expect(result).toBe(true);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName); 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

@@ -3,7 +3,6 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@@ -14,10 +13,8 @@ import { WindowMain } from "../../main/window.main";
import { OsBiometricService } from "./os-biometrics.service"; import { OsBiometricService } from "./os-biometrics.service";
const KEY_WITNESS_SUFFIX = "_witness";
const WITNESS_VALUE = "known key";
const SERVICE = "Bitwarden_biometric"; const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string { function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`; return `${userId}_user_biometric`;
} }
@@ -43,18 +40,25 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
} }
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> { async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); const success = await this.authenticateBiometric();
let clientKeyHalfB64: string | null = null; if (!success) {
if (this.clientKeyHalves.has(userId)) { return null;
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId));
} }
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (value == null || value == "") { if (value == null || value == "") {
return null; throw new Error("Biometric key not found for user");
} else if (!EncString.isSerializedEncString(value)) { }
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 // Update to format encrypted with client key half
const storageDetails = await this.getStorageDetails({ const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64, clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
}); });
await biometrics.setBiometricSecret( await biometrics.setBiometricSecret(
@@ -69,7 +73,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
const encValue = new EncString(value); const encValue = new EncString(value);
this.setIv(encValue.iv); this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64, clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
}); });
return SymmetricCryptoKey.fromString( return SymmetricCryptoKey.fromString(
await biometrics.getBiometricSecret( await biometrics.getBiometricSecret(
@@ -84,35 +88,16 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> { async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
if (
await this.valueUpToDate({
value: key,
clientKeyPartB64: Utils.fromBufferToB64(clientKeyHalf),
service: SERVICE,
storageKey: getLookupKeyForUser(userId),
})
) {
return;
}
const storageDetails = await this.getStorageDetails({ const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf), clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
}); });
const storedValue = await biometrics.setBiometricSecret( await biometrics.setBiometricSecret(
SERVICE, SERVICE,
getLookupKeyForUser(userId), getLookupKeyForUser(userId),
key.toBase64(), key.toBase64(),
storageDetails.key_material, storageDetails.key_material,
storageDetails.ivB64, storageDetails.ivB64,
); );
const parsedStoredValue = new EncString(storedValue);
await this.storeValueWitness(
key,
parsedStoredValue,
SERVICE,
getLookupKeyForUser(userId),
Utils.fromBufferToB64(clientKeyHalf),
);
} }
async deleteBiometricKey(userId: UserId): Promise<void> { async deleteBiometricKey(userId: UserId): Promise<void> {
@@ -129,21 +114,11 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
throw e; throw e;
} }
} }
try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX);
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric witness key %s not found for service %s.",
getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX,
SERVICE,
);
} else {
throw e;
}
}
} }
/**
* Prompts Windows Hello
*/
async authenticateBiometric(): Promise<boolean> { async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle(); const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage")); return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
@@ -155,7 +130,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
clientKeyHalfB64: string | undefined; clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) { if (this._osKeyHalf == null) {
// Prompts Windows Hello
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
this._osKeyHalf = keyMaterial.keyB64; this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64; this._iv = keyMaterial.ivB64;
@@ -187,118 +161,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
this._osKeyHalf = null; this._osKeyHalf = null;
} }
/**
* Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date.
*
* @param unencryptedValue The key to store
* @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key.
* @param service The service to store the witness key under
* @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns
*/
private async storeValueWitness(
unencryptedValue: SymmetricCryptoKey,
encryptedValue: EncString,
service: string,
storageKey: string,
clientKeyPartB64: string | undefined,
) {
if (encryptedValue.iv == null) {
return;
}
const storageDetails = {
keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64),
ivB64: encryptedValue.iv,
};
await biometrics.setBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
WITNESS_VALUE,
storageDetails.keyMaterial,
storageDetails.ivB64,
);
}
/**
* Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
* @param value The value being validated
* @param service The service the value is stored under
* @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns Boolean indicating if the value is up to date.
*/
// Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
private async valueUpToDate({
value,
clientKeyPartB64,
service,
storageKey,
}: {
value: SymmetricCryptoKey;
clientKeyPartB64: string | undefined;
service: string;
storageKey: string;
}): Promise<boolean> {
const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64);
if (witnessKeyMaterial == null) {
return false;
}
let witness = null;
try {
witness = await biometrics.getBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
witnessKeyMaterial,
);
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric witness key %s not found for service %s, value is not up to date.",
storageKey + KEY_WITNESS_SUFFIX,
service,
);
} else {
this.logService.error(
"[OsBiometricService] Error retrieving witness key, assuming value is not up to date.",
e,
);
}
return false;
}
if (witness === WITNESS_VALUE) {
return true;
}
return false;
}
/** Derives a witness key from a symmetric key being stored for biometric protection */
private witnessKeyMaterial(
symmetricKey: SymmetricCryptoKey,
clientKeyPartB64: string | undefined,
): biometrics.KeyMaterial {
let key = null;
const innerKey = symmetricKey.inner();
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
key = Utils.fromBufferToB64(innerKey.authenticationKey);
} else {
key = Utils.fromBufferToB64(innerKey.encryptionKey);
}
const result = {
osKeyPartB64: key,
clientKeyPartB64,
};
// napi-rs fails to convert null values
if (result.clientKeyPartB64 == null) {
delete result.clientKeyPartB64;
}
return result;
}
async needsSetup() { async needsSetup() {
return false; return false;
} }
@@ -312,14 +174,9 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
async getOrCreateBiometricEncryptionClientKeyHalf( async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId, userId: UserId,
key: SymmetricCryptoKey, key: SymmetricCryptoKey,
): Promise<Uint8Array | null> { ): Promise<Uint8Array> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
if (this.clientKeyHalves.has(userId)) { if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId); return this.clientKeyHalves.get(userId)!;
} }
// Retrieve existing key half if it exists // Retrieve existing key half if it exists
@@ -331,8 +188,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
} }
if (clientKeyHalf == null) { if (clientKeyHalf == null) {
// Set a key half if it doesn't exist // Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32); clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key); const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
} }
@@ -342,11 +199,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
} }
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> { async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return BiometricsStatus.Available;
}
if (this.clientKeyHalves.has(userId)) { if (this.clientKeyHalves.has(userId)) {
return BiometricsStatus.Available; return BiometricsStatus.Available;
} else { } else {

View File

@@ -1813,9 +1813,6 @@
"unlockWithWindowsHello": { "unlockWithWindowsHello": {
"message": "Unlock with Windows Hello" "message": "Unlock with Windows Hello"
}, },
"additionalWindowsHelloSettings": {
"message": "Additional Windows Hello settings"
},
"unlockWithPolkit": { "unlockWithPolkit": {
"message": "Unlock with system authentication" "message": "Unlock with system authentication"
}, },
@@ -1831,12 +1828,6 @@
"touchIdConsentMessage": { "touchIdConsentMessage": {
"message": "unlock your vault" "message": "unlock your vault"
}, },
"autoPromptWindowsHello": {
"message": "Ask for Windows Hello on app start"
},
"autoPromptPolkit": {
"message": "Ask for system authentication on launch"
},
"autoPromptTouchId": { "autoPromptTouchId": {
"message": "Ask for Touch ID on app start" "message": "Ask for Touch ID on app start"
}, },
@@ -1846,9 +1837,6 @@
"requirePasswordWithoutPinOnStart": { "requirePasswordWithoutPinOnStart": {
"message": "Require password on app start" "message": "Require password on app start"
}, },
"recommendedForSecurity": {
"message": "Recommended for security."
},
"lockWithMasterPassOnRestart1": { "lockWithMasterPassOnRestart1": {
"message": "Lock with master password on restart" "message": "Lock with master password on restart"
}, },

View File

@@ -201,6 +201,16 @@ export class Main {
this.logService, this.logService,
true, true,
); );
this.windowMain = new WindowMain(
biometricStateService,
this.logService,
this.storageService,
this.desktopSettingsService,
(arg) => this.processDeepLink(arg),
(win) => this.trayMain.setupWindowListeners(win),
);
this.biometricsService = new MainBiometricsService( this.biometricsService = new MainBiometricsService(
this.i18nService, this.i18nService,
this.windowMain, this.windowMain,
@@ -211,14 +221,6 @@ export class Main {
this.mainCryptoFunctionService, this.mainCryptoFunctionService,
); );
this.windowMain = new WindowMain(
biometricStateService,
this.logService,
this.storageService,
this.desktopSettingsService,
(arg) => this.processDeepLink(arg),
(win) => this.trayMain.setupWindowListeners(win),
);
this.messagingMain = new MessagingMain(this, this.desktopSettingsService); this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain); this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);

View File

@@ -3,5 +3,6 @@
"angularCompilerOptions": { "angularCompilerOptions": {
"strictTemplates": true "strictTemplates": true
}, },
"include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"] "include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"],
"exclude": ["src/**/*.spec.ts"]
} }