1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-10-20 15:17:36 -07:00
60 changed files with 2100 additions and 291 deletions

View File

@@ -87,7 +87,7 @@ export default {
props: args,
template: /*html*/ `
<auth-anon-layout
[hideIcon]="true"
[icon]="null"
[hideBackgroundIllustration]="true"
>
<dirt-phishing-warning></dirt-phishing-warning>

View File

@@ -23,6 +23,7 @@ import {
UserLockIcon,
VaultIcon,
LockIcon,
DomainIcon,
TwoFactorAuthSecurityKeyIcon,
} from "@bitwarden/assets/svg";
import {
@@ -565,6 +566,8 @@ const routes: Routes = [
key: "verifyYourIdentity",
},
showBackButton: true,
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
pageIcon: null,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
},
{
@@ -572,6 +575,7 @@ const routes: Routes = [
data: {
elevation: 1,
hideFooter: true,
pageIcon: LockIcon,
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
children: [
{
@@ -617,9 +621,9 @@ const routes: Routes = [
path: "",
component: IntroCarouselComponent,
data: {
hideIcon: true,
pageIcon: null,
hideFooter: true,
},
} satisfies ExtensionAnonLayoutWrapperData,
},
],
},
@@ -637,6 +641,7 @@ const routes: Routes = [
key: "confirmKeyConnectorDomain",
},
showBackButton: true,
pageIcon: DomainIcon,
} satisfies ExtensionAnonLayoutWrapperData,
},
],
@@ -722,7 +727,7 @@ const routes: Routes = [
},
],
data: {
hideIcon: true,
pageIcon: null,
hideBackgroundIllustration: true,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,

View File

@@ -11,13 +11,15 @@ export class ExtensionAnonLayoutWrapperDataService
extends DefaultAnonLayoutWrapperDataService
implements AnonLayoutWrapperDataService
{
protected override anonLayoutWrapperDataSubject = new Subject<ExtensionAnonLayoutWrapperData>();
protected override anonLayoutWrapperDataSubject = new Subject<
Partial<ExtensionAnonLayoutWrapperData>
>();
override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void {
override setAnonLayoutWrapperData(data: Partial<ExtensionAnonLayoutWrapperData>): void {
this.anonLayoutWrapperDataSubject.next(data);
}
override anonLayoutWrapperData$(): Observable<ExtensionAnonLayoutWrapperData> {
override anonLayoutWrapperData$(): Observable<Partial<ExtensionAnonLayoutWrapperData>> {
return this.anonLayoutWrapperDataSubject.asObservable();
}
}

View File

@@ -23,7 +23,6 @@
[hideLogo]="true"
[maxWidth]="maxWidth"
[hideFooter]="hideFooter"
[hideIcon]="hideIcon"
[hideCardWrapper]="hideCardWrapper"
>
<router-outlet></router-outlet>

View File

@@ -27,7 +27,6 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
showBackButton?: boolean;
showLogo?: boolean;
hideFooter?: boolean;
hideIcon?: boolean;
}
@Component({
@@ -50,7 +49,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
protected showAcctSwitcher: boolean;
protected showBackButton: boolean;
protected showLogo: boolean = true;
protected hideIcon: boolean = false;
protected pageTitle: string;
protected pageSubtitle: string;
@@ -134,10 +132,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.showLogo = Boolean(firstChildRouteData["showLogo"]);
}
if (firstChildRouteData["hideIcon"] !== undefined) {
this.hideIcon = Boolean(firstChildRouteData["hideIcon"]);
}
if (firstChildRouteData["hideCardWrapper"] !== undefined) {
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
}
@@ -196,10 +190,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
if (data.showLogo !== undefined) {
this.showLogo = data.showLogo;
}
if (data.hideIcon !== undefined) {
this.hideIcon = data.hideIcon;
}
}
private handleStringOrTranslation(value: string | Translation): string {
@@ -222,7 +212,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
this.showLogo = null;
this.maxWidth = null;
this.hideFooter = null;
this.hideIcon = null;
this.hideCardWrapper = null;
}

View File

@@ -208,7 +208,9 @@ export const DefaultContentExample: Story = {
children: [
{
path: "default-example",
data: {},
data: {
pageIcon: LockIcon,
} satisfies ExtensionAnonLayoutWrapperData,
children: [
{
path: "",
@@ -244,7 +246,6 @@ const initialData: ExtensionAnonLayoutWrapperData = {
showAcctSwitcher: true,
showBackButton: true,
showLogo: true,
hideIcon: false,
};
const changedData: ExtensionAnonLayoutWrapperData = {
@@ -258,7 +259,6 @@ const changedData: ExtensionAnonLayoutWrapperData = {
showAcctSwitcher: false,
showBackButton: false,
showLogo: false,
hideIcon: false,
};
@Component({
@@ -337,9 +337,9 @@ export const HasLoggedInAccountExample: Story = {
{
path: "has-logged-in-account",
data: {
hasLoggedInAccount: true,
showAcctSwitcher: true,
},
pageIcon: LockIcon,
} satisfies ExtensionAnonLayoutWrapperData,
children: [
{
path: "",

View File

@@ -591,6 +591,19 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -904,6 +917,7 @@ dependencies = [
"byteorder",
"bytes",
"cbc",
"chacha20poly1305",
"core-foundation",
"desktop_objc",
"dirs",
@@ -923,6 +937,8 @@ dependencies = [
"secmem-proc",
"security-framework",
"security-framework-sys",
"serde",
"serde_json",
"sha2",
"ssh-encoding",
"ssh-key",

View File

@@ -27,6 +27,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", re
byteorder = "=1.5.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"
ctor = "=0.5.0"
dirs = "=6.0.0"

View File

@@ -26,6 +26,7 @@ bitwarden-russh = { workspace = true }
byteorder = { workspace = true }
bytes = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
chacha20poly1305 = { workspace = true }
dirs = { workspace = true }
ed25519 = { workspace = true, features = ["pkcs8"] }
futures = { workspace = true }
@@ -38,6 +39,8 @@ rsa = { workspace = true }
russh-cryptovec = { workspace = true }
scopeguard = { workspace = true }
secmem-proc = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
ssh-encoding = { workspace = true }
ssh-key = { workspace = true, features = [
@@ -64,6 +67,7 @@ windows = { workspace = true, features = [
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_Security_Cryptography",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",

View File

@@ -0,0 +1,33 @@
use anyhow::Result;
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unimplemented.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric_v2;
#[cfg(target_os = "windows")]
pub mod windows_focus;
pub use biometric_v2::BiometricLockSystem;
#[allow(async_fn_in_trait)]
pub trait BiometricTrait: Send + Sync {
/// Authenticate the user
async fn authenticate(&self, hwnd: Vec<u8>, message: String) -> Result<bool>;
/// Check if biometric authentication is available
async fn authenticate_available(&self) -> Result<bool>;
/// Enroll a key for persistent unlock. If the implementation does not support persistent enrollment,
/// this function should do nothing.
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>;
/// Clear the persistent and ephemeral keys
async fn unenroll(&self, user_id: &str) -> Result<()>;
/// Check if a persistent (survives app restarts and reboots) key is set for a user
async fn has_persistent(&self, user_id: &str) -> Result<bool>;
/// Provide a key to be ephemerally held. This should be called on every unlock.
async fn provide_key(&self, user_id: &str, key: &[u8]);
/// Perform biometric unlock and return the key
async fn unlock(&self, user_id: &str, hwnd: Vec<u8>) -> Result<Vec<u8>>;
/// Check if biometric unlock is available based on whether a key is present and whether authentication is possible
async fn unlock_available(&self, user_id: &str) -> Result<bool>;
}

View File

@@ -0,0 +1,47 @@
pub struct BiometricLockSystem {}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {}
}
}
impl Default for BiometricLockSystem {
fn default() -> Self {
Self::new()
}
}
impl super::BiometricTrait for BiometricLockSystem {
async fn authenticate(&self, _hwnd: Vec<u8>, _message: String) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn authenticate_available(&self) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> {
unimplemented!()
}
async fn provide_key(&self, _user_id: &str, _key: &[u8]) {
unimplemented!()
}
async fn unlock(&self, _user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
unimplemented!()
}
async fn unlock_available(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn has_persistent(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> {
unimplemented!()
}
}

View File

@@ -0,0 +1,505 @@
//! This file implements Windows-Hello based biometric unlock.
//!
//! There are two paths implemented here.
//! The former via UV + ephemerally (but protected) keys. This only works after first unlock.
//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key
//! is used to encrypt the protected key.
//!
//! # Security
//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space)
//! is compromised in this state.
//!
//! ## UV path
//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory,
//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel.
//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via
//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app.
//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may
//! circumvent DPAPI.
//!
//! ## Sign path
//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key
//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic
//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key).
//!
//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes.
//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and
//! get the user to confirm it.
use std::sync::{atomic::AtomicBool, Arc};
use tracing::{debug, warn};
use aes::cipher::KeyInit;
use anyhow::{anyhow, Result};
use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use windows::{
core::{factory, h, Interface, HSTRING},
Security::{
Credentials::{
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus,
UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
},
Cryptography::CryptographicBuffer,
},
Storage::Streams::IBuffer,
Win32::{
System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop},
UI::WindowsAndMessaging::GetForegroundWindow,
},
};
use windows_future::IAsyncOperation;
use super::windows_focus::{focus_security_prompt, restore_focus};
use crate::{
password::{self, PASSWORD_NOT_FOUND},
secure_memory::*,
};
const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2";
const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2");
const CHALLENGE_LENGTH: usize = 16;
const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24;
const XCHACHA20POLY1305_KEY_LENGTH: usize = 32;
#[derive(serde::Serialize, serde::Deserialize)]
struct WindowsHelloKeychainEntry {
nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH],
challenge: [u8; CHALLENGE_LENGTH],
wrapped_key: Vec<u8>,
}
/// The Windows OS implementation of the biometric trait.
pub struct BiometricLockSystem {
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure
// locked vaults cannot be unlocked
secure_memory: Arc<Mutex<crate::secure_memory::dpapi::DpapiSecretKVStore>>,
}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {
secure_memory: Arc::new(Mutex::new(
crate::secure_memory::dpapi::DpapiSecretKVStore::new(),
)),
}
}
}
impl Default for BiometricLockSystem {
fn default() -> Self {
Self::new()
}
}
impl super::BiometricTrait for BiometricLockSystem {
async fn authenticate(&self, _hwnd: Vec<u8>, message: String) -> Result<bool> {
windows_hello_authenticate(message).await
}
async fn authenticate_available(&self) -> Result<bool> {
match UserConsentVerifier::CheckAvailabilityAsync()?.await? {
UserConsentVerifierAvailability::Available
| UserConsentVerifierAvailability::DeviceBusy => Ok(true),
_ => Ok(false),
}
}
async fn unenroll(&self, user_id: &str) -> Result<()> {
self.secure_memory.lock().await.remove(user_id);
delete_keychain_entry(user_id).await
}
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> {
// Enrollment works by first generating a random challenge unique to the user / enrollment. Then,
// with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows
// hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce,
// challenge and wrapped-key are stored to the keychain
// Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique
let challenge: [u8; CHALLENGE_LENGTH] = rand::random();
// This key is unique to the challenge
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?;
let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?;
set_keychain_entry(
user_id,
&WindowsHelloKeychainEntry {
nonce,
challenge,
wrapped_key,
},
)
.await
}
async fn provide_key(&self, user_id: &str, key: &[u8]) {
self.secure_memory
.lock()
.await
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
// Allow restoring focus to the previous window (browser)
let previous_active_window = super::windows_focus::get_active_window();
let _focus_scopeguard = scopeguard::guard((), |_| {
if let Some(hwnd) = previous_active_window {
debug!("Restoring focus to previous window");
restore_focus(hwnd.0);
}
});
let mut secure_memory = self.secure_memory.lock().await;
// If the key is held ephemerally, always use UV API. Only use signing API if the key is not held
// ephemerally but the keychain holds it persistently.
if secure_memory.has(user_id) {
if windows_hello_authenticate("Unlock your vault".to_string()).await? {
secure_memory
.get(user_id)
.clone()
.ok_or_else(|| anyhow!("No key found for user"))
} else {
Err(anyhow!("Authentication failed"))
}
} else {
let keychain_entry = get_keychain_entry(user_id).await?;
let windows_hello_key =
windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?;
let decrypted_key = decrypt_data(
&windows_hello_key,
&keychain_entry.wrapped_key,
&keychain_entry.nonce,
)?;
// The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes.
secure_memory.put(user_id.to_string(), &decrypted_key.clone());
Ok(decrypted_key)
}
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
let secure_memory = self.secure_memory.lock().await;
let has_key =
secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false);
Ok(has_key && self.authenticate_available().await.unwrap_or(false))
}
async fn has_persistent(&self, user_id: &str) -> Result<bool> {
Ok(get_keychain_entry(user_id).await.is_ok())
}
}
/// Get a yes/no authorization without any cryptographic backing.
/// This API has better focusing behavior
async fn windows_hello_authenticate(message: String) -> Result<bool> {
debug!(
"[Windows Hello] Authenticating to perform UV with message: {}",
message
);
let userconsent_result: IAsyncOperation<UserConsentVerificationResult> = unsafe {
// 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 = GetForegroundWindow();
factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?
.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
match userconsent_result.await? {
UserConsentVerificationResult::Verified => Ok(true),
_ => Ok(false),
}
}
/// 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.
///
/// Note: This API has inconsistent focusing behavior when called from another window
async fn windows_hello_authenticate_with_crypto(
challenge: &[u8; CHALLENGE_LENGTH],
) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> {
debug!("[Windows Hello] Authenticating to sign challenge");
// Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API.
// This is unreliable, and if it does not work, the operation may fail
let stop_focusing = Arc::new(AtomicBool::new(false));
let stop_focusing_clone = stop_focusing.clone();
let _ = std::thread::spawn(move || loop {
if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
// Only stop focusing once this function exits. The focus MUST run both during the initial creation
// with RequestCreateAsync, and also with the subsequent use with RequestSignAsync.
let _guard = scopeguard::guard((), |_| {
stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed);
});
// First create or replace the Bitwarden Biometrics signing key
let credential = {
let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync(
CREDENTIAL_NAME,
KeyCredentialCreationOption::FailIfExists,
)?
.await?;
match key_credential_creation_result.Status()? {
KeyCredentialStatus::CredentialAlreadyExists => {
KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await?
}
KeyCredentialStatus::Success => key_credential_creation_result,
_ => return Err(anyhow!("Failed to create key credential")),
}
}
.Credential()?;
let signature = {
let sign_operation = credential.RequestSignAsync(
&CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?,
)?;
// We need to drop the credential here to avoid holding it across an await point.
drop(credential);
sign_operation.await?
};
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
}
let signature_buffer = signature.Result()?;
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? };
// The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key.
// It is unclear what entropy this key provides.
let windows_hello_key = Sha256::digest(signature_value).into();
Ok(windows_hello_key)
}
async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> {
password::set_password(
KEYCHAIN_SERVICE_NAME,
user_id,
&serde_json::to_string(entry)?,
)
.await
}
async fn get_keychain_entry(user_id: &str) -> Result<WindowsHelloKeychainEntry> {
serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?)
.map_err(|e| anyhow!(e))
}
async fn delete_keychain_entry(user_id: &str) -> Result<()> {
password::delete_password(KEYCHAIN_SERVICE_NAME, user_id)
.await
.or_else(|e| {
if e.to_string() == PASSWORD_NOT_FOUND {
debug!(
"[Windows Hello] No keychain entry found for user {}, nothing to delete",
user_id
);
Ok(())
} else {
Err(e)
}
})
}
async fn has_keychain_entry(user_id: &str) -> Result<bool> {
password::get_password(KEYCHAIN_SERVICE_NAME, user_id)
.await
.map(|entry| !entry.is_empty())
.or_else(|e| {
if e.to_string() == PASSWORD_NOT_FOUND {
Ok(false)
} else {
warn!(
"[Windows Hello] Error checking keychain entry for user {}: {}",
user_id, e
);
Err(e)
}
})
}
/// Encrypt data with XChaCha20Poly1305
fn encrypt_data(
key: &[u8; XCHACHA20POLY1305_KEY_LENGTH],
plaintext: &[u8],
) -> Result<(Vec<u8>, [u8; XCHACHA20POLY1305_NONCE_LENGTH])> {
let cipher = XChaCha20Poly1305::new(key.into());
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LENGTH];
rand::fill(&mut nonce);
let ciphertext = cipher
.encrypt(XNonce::from_slice(&nonce), plaintext)
.map_err(|e| anyhow!(e))?;
Ok((ciphertext, nonce))
}
/// Decrypt data with XChaCha20Poly1305
fn decrypt_data(
key: &[u8; XCHACHA20POLY1305_KEY_LENGTH],
ciphertext: &[u8],
nonce: &[u8; XCHACHA20POLY1305_NONCE_LENGTH],
) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(XNonce::from_slice(nonce), ciphertext)
.map_err(|e| anyhow!(e))?;
Ok(plaintext)
}
unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> {
let interop = buffer.cast::<IBufferByteAccess>()?;
unsafe {
let data = interop.Buffer()?;
Ok(std::slice::from_raw_parts_mut(
data,
buffer.Length()? as usize,
))
}
}
#[cfg(test)]
mod tests {
use crate::biometric_v2::{
biometric_v2::{
decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate,
windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH,
},
BiometricLockSystem, BiometricTrait,
};
#[test]
fn test_encrypt_decrypt() {
let key = [0u8; 32];
let plaintext = b"Test data";
let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap();
let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap();
assert_eq!(plaintext.to_vec(), decrypted);
}
#[tokio::test]
async fn test_has_keychain_entry_no_entry() {
let user_id = "test_user";
let has_entry = has_keychain_entry(user_id).await.unwrap();
assert!(!has_entry);
}
// Note: These tests are ignored because they require manual intervention to run
#[tokio::test]
#[ignore]
async fn test_windows_hello_authenticate_with_crypto_manual() {
let challenge = [0u8; CHALLENGE_LENGTH];
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)
.await
.unwrap();
println!(
"Windows hello key {:?} for challenge {:?}",
windows_hello_key, challenge
);
}
#[tokio::test]
#[ignore]
async fn test_windows_hello_authenticate() {
let authenticated =
windows_hello_authenticate("Test Windows Hello authentication".to_string())
.await
.unwrap();
println!("Windows Hello authentication result: {:?}", authenticated);
}
#[tokio::test]
#[ignore]
async fn test_double_unenroll() {
let user_id = "test_user";
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
let windows_hello_lock_system = BiometricLockSystem::new();
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unenrolling user again");
// This throws PASSWORD_NOT_FOUND but our code should handle that and not throw.
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
}
#[tokio::test]
#[ignore]
async fn test_enroll_unlock_unenroll() {
let user_id = "test_user";
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
let windows_hello_lock_system = BiometricLockSystem::new();
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
}
}

View File

@@ -0,0 +1,100 @@
use windows::{
core::s,
Win32::{
Foundation::HWND,
System::Threading::{AttachThreadInput, GetCurrentThreadId},
UI::{
Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus},
WindowsAndMessaging::{
BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId,
SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE,
SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT,
},
},
},
};
pub(crate) struct HwndHolder(pub(crate) HWND);
unsafe impl Send for HwndHolder {}
pub(crate) fn get_active_window() -> Option<HwndHolder> {
unsafe { Some(HwndHolder(GetForegroundWindow())) }
}
/// 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 hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) };
if let Ok(hwnd) = hwnd_result {
set_focus(hwnd);
}
}
/// Sets focus to a window using a few unstable methods
fn set_focus(hwnd: HWND) {
unsafe {
// Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs.
// The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does
// not focus itself.
// This function implements forced focusing of windows using a few hacks.
// The conditions to successfully foreground a window are:
// All of the following conditions are true:
// The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1.
// The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function.
// The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo).
// No menus are active.
// Additionally, at least one of the following conditions is true:
// The calling process is the foreground process.
// The calling process was started by the foreground process.
// There is currently no foreground window, and thus no foreground process.
// The calling process received the last input event.
// Either the foreground process or the calling process is being debugged.
// Update the foreground lock timeout temporarily
let mut old_timeout = 0;
let _ = SystemParametersInfoW(
SPI_GETFOREGROUNDLOCKTIMEOUT,
0,
Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void),
windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
);
let _ = SystemParametersInfoW(
SPI_SETFOREGROUNDLOCKTIMEOUT,
0,
None,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
);
let _scopeguard = scopeguard::guard((), |_| {
let _ = SystemParametersInfoW(
SPI_SETFOREGROUNDLOCKTIMEOUT,
old_timeout,
None,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
);
});
// Attach to the foreground thread once attached, we can foreground, even if in the background
let dw_current_thread = GetCurrentThreadId();
let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None);
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true);
let _ = SetForegroundWindow(hwnd);
SetCapture(hwnd);
let _ = SetFocus(Some(hwnd));
let _ = SetActiveWindow(hwnd);
let _ = EnableWindow(hwnd, true);
let _ = BringWindowToTop(hwnd);
SwitchToThisWindow(hwnd, true);
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false);
}
}
/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen.
pub(crate) fn restore_focus(hwnd: HWND) {
unsafe {
let _ = SetForegroundWindow(hwnd);
let _ = SetFocus(Some(hwnd));
}
}

View File

@@ -1,13 +1,15 @@
pub mod autofill;
pub mod autostart;
pub mod biometric;
pub mod biometric_v2;
pub mod clipboard;
pub mod crypto;
pub(crate) mod crypto;
pub mod error;
pub mod ipc;
pub mod password;
pub mod powermonitor;
pub mod process_isolation;
pub(crate) mod secure_memory;
pub mod ssh_agent;
use zeroizing_alloc::ZeroAlloc;

View File

@@ -0,0 +1,134 @@
use std::collections::HashMap;
use windows::Win32::Security::Cryptography::{
CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE,
CRYPTPROTECTMEMORY_SAME_PROCESS,
};
use crate::secure_memory::SecureMemoryStore;
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
/// to the current process, and cannot be decrypted by other user-mode processes.
///
/// Note: Admin processes can still decrypt this memory:
/// https://blog.slowerzs.net/posts/cryptdecryptmemory/
pub(crate) struct DpapiSecretKVStore {
map: HashMap<String, Vec<u8>>,
}
impl DpapiSecretKVStore {
pub(crate) fn new() -> Self {
DpapiSecretKVStore {
map: HashMap::new(),
}
}
}
impl SecureMemoryStore for DpapiSecretKVStore {
fn put(&mut self, key: String, value: &[u8]) {
let length_header_len = std::mem::size_of::<usize>();
// The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front
// We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple
// of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros.
let data_len = value.len();
let len_with_header = data_len + length_header_len;
let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize
- (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize);
let mut padded_data = vec![0u8; padded_length];
padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes());
padded_data[length_header_len..][..data_len].copy_from_slice(value);
// Protect the memory using DPAPI
unsafe {
CryptProtectMemory(
padded_data.as_mut_ptr() as *mut core::ffi::c_void,
padded_length as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_protect_memory should work");
self.map.insert(key, padded_data);
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.map.get(key).map(|data| {
// A copy is created, that is then mutated by the DPAPI unprotect function.
let mut data = data.clone();
unsafe {
CryptUnprotectMemory(
data.as_mut_ptr() as *mut core::ffi::c_void,
data.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_unprotect_memory should work");
// Unpad the data to retrieve the original value
let length_header_size = std::mem::size_of::<usize>();
let length_bytes = &data[..length_header_size];
let data_length = usize::from_le_bytes(
length_bytes
.try_into()
.expect("length header should be usize"),
);
data[length_header_size..length_header_size + data_length].to_vec()
})
}
fn has(&self, key: &str) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
fn clear(&mut self) {
self.map.clear();
}
}
impl Drop for DpapiSecretKVStore {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dpapi_secret_kv_store_various_sizes() {
let mut store = DpapiSecretKVStore::new();
for size in 0..=2048 {
let key = format!("test_key_{}", size);
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert_eq!(
store.get(&key),
Some(value),
"Value mismatch for size {}",
size
);
}
}
#[test]
fn test_dpapi_crud() {
let mut store = DpapiSecretKVStore::new();
let key = "test_key".to_string();
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}
}

View File

@@ -0,0 +1,22 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
/// The secure memory store provides an ephemeral key-value store for sensitive data.
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
/// platform-specific protections are applied to prevent memory dumps or debugger access from
/// reading the stored values.
#[allow(unused)]
pub(crate) trait SecureMemoryStore {
/// Stores a copy of the provided value in secure memory.
fn put(&mut self, key: String, value: &[u8]);
/// Retrieves a copy of the value associated with the given key from secure memory.
/// This copy does not have additional memory protections applied, and should be zeroed when no
/// longer needed.
fn get(&self, key: &str) -> Option<Vec<u8>>;
/// Checks if a value is stored under the given key.
fn has(&self, key: &str) -> bool;
/// Removes the value associated with the given key from secure memory.
fn remove(&mut self, key: &str);
/// Clears all values stored in secure memory.
fn clear(&mut self);
}

View File

@@ -58,6 +58,18 @@ export declare namespace biometrics {
ivB64: string
}
}
export declare namespace biometrics_v2 {
export function initBiometricSystem(): BiometricLockSystem
export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise<boolean>
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise<Buffer>
export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise<void>
export class BiometricLockSystem { }
}
export declare namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>

View File

@@ -149,6 +149,123 @@ pub mod biometrics {
}
}
#[napi]
pub mod biometrics_v2 {
use desktop_core::biometric_v2::BiometricTrait;
#[napi]
pub struct BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem,
}
#[napi]
pub fn init_biometric_system() -> napi::Result<BiometricLockSystem> {
Ok(BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
})
}
#[napi]
pub async fn authenticate(
biometric_lock_system: &BiometricLockSystem,
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn authenticate_available(
biometric_lock_system: &BiometricLockSystem,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn enroll_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.enroll_persistent(&user_id, &key)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn provide_key(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.provide_key(&user_id, &key)
.await;
Ok(())
}
#[napi]
pub async fn unlock(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
hwnd: napi::bindgen_prelude::Buffer,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
biometric_lock_system
.inner
.unlock(&user_id, hwnd.into())
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.into())
}
#[napi]
pub async fn unlock_available(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.unlock_available(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn has_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.has_persistent(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn unenroll(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<()> {
biometric_lock_system
.inner
.unenroll(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
pub mod clipboards {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!

View File

@@ -81,6 +81,31 @@
"additionalTouchIdSettings" | i18n
}}</small>
</div>
<div
class="form-group"
*ngIf="
supportsBiometric &&
form.value.biometric &&
isWindows &&
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
isWindowsV2BiometricsEnabled
"
>
<div class="checkbox form-group-child">
<label for="requireMasterPasswordOnAppRestart">
<input
id="requireMasterPasswordOnAppRestart"
type="checkbox"
formControlName="requireMasterPasswordOnAppRestart"
/>
@if (pinEnabled$ | async) {
{{ "requireMasterPasswordOrPinOnAppRestart" | i18n }}
} @else {
{{ "requireMasterPasswordOnAppRestart" | i18n }}
}
</label>
</div>
</div>
<div
class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && this.isMac"

View File

@@ -30,6 +30,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
import { ThemeType } from "@bitwarden/common/platform/enums";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
@@ -73,6 +74,9 @@ describe("SettingsComponent", () => {
const desktopAutotypeService = mock<DesktopAutotypeService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const configService = mock<ConfigService>();
const userVerificationService = mock<UserVerificationService>();
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64));
beforeEach(async () => {
jest.clearAllMocks();
@@ -92,6 +96,7 @@ describe("SettingsComponent", () => {
};
i18nService.supportedTranslationLocales = [];
i18nService.t.mockImplementation((key: string) => key);
await TestBed.configureTestingModule({
imports: [],
@@ -124,7 +129,7 @@ describe("SettingsComponent", () => {
{ provide: PolicyService, useValue: policyService },
{ provide: StateService, useValue: mock<StateService>() },
{ provide: ThemeStateService, useValue: themeStateService },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: UserVerificationService, useValue: userVerificationService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: ValidationService, useValue: validationService },
{ provide: MessagingService, useValue: messagingService },
@@ -153,6 +158,7 @@ describe("SettingsComponent", () => {
component = fixture.componentInstance;
fixture.detectChanges();
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
of(VaultTimeoutStringType.OnLocked),
);
@@ -296,43 +302,81 @@ 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);
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();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
test.each([true, false])(
`correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`,
async (policyEnabled) => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = policyEnabled;
policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture);
if (policyEnabled) {
expect(textNodes).toContain("requireMasterPasswordOnAppRestart");
} else {
expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart");
}
},
);
describe("users without a master password", () => {
beforeEach(() => {
userVerificationService.hasMasterPassword.mockResolvedValue(false);
});
it("displays require MP/PIN on app restart checkbox when pin is set", async () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
checkRequireMasterPasswordOnAppRestartElement(fixture);
});
it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
await component.ngOnInit();
fixture.detectChanges();
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
By.css("label[for='requireMasterPasswordOnAppRestart']"),
);
expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull();
});
});
it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
function checkRequireMasterPasswordOnAppRestartElement(
fixture: ComponentFixture<SettingsComponent>,
) {
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
By.css("label[for='requireMasterPasswordOnAppRestart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
});
expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull();
expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1);
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input");
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({
id: "requireMasterPasswordOnAppRestart",
type: "checkbox",
});
const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
return textNodes;
}
});
});
@@ -362,7 +406,7 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(false);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
@@ -378,7 +422,7 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(dialogResult);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
@@ -390,9 +434,147 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
describe("when windows biometric v2 feature flag is enabled", () => {
beforeEach(() => {
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
test.each([false, true])(
"enrolls persistent biometric if needed, enrolled is %s",
async (enrolled) => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart = true;
component.userHasMasterPassword = false;
component.supportsBiometric = true;
component.form.value.biometric = true;
await component.updatePinHandler(false);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
expect(component.form.controls.pin.value).toBe(false);
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
if (enrolled) {
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
} else {
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
}
},
);
test.each([
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: true,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
])(
"does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart",
async ({
userHasMasterPassword,
supportsBiometric,
biometric,
requireMasterPasswordOnAppRestart,
}) => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart =
requireMasterPasswordOnAppRestart;
component.userHasMasterPassword = userHasMasterPassword;
component.supportsBiometric = supportsBiometric;
component.form.value.biometric = biometric;
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
},
);
});
});
});
@@ -474,22 +656,92 @@ describe("SettingsComponent", () => {
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
it("handles windows case", async () => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
describe("windows test cases", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
component.isWindows = true;
component.isLinux = false;
component.isWindows = true;
component.isLinux = false;
await component.updateBiometricHandler(true);
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
BiometricsStatus.Available,
);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
});
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
it("handles windows case", async () => {
await component.updateBiometricHandler(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
describe("when windows v2 biometrics is enabled", () => {
beforeEach(() => {
component.isWindowsV2BiometricsEnabled = true;
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => {
component.userHasMasterPassword = false;
component.userHasPinSet = false;
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.updateBiometricHandler(true);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([
[true, true],
[true, false],
[false, true],
])(
"when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting",
async (userHasMasterPassword, userHasPinSet) => {
component.userHasMasterPassword = userHasMasterPassword;
component.userHasPinSet = userHasPinSet;
await component.updateBiometricHandler(true);
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true);
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
);
expect(
desktopBiometricsService.setBiometricProtectedUnlockKeyForUser,
).toHaveBeenCalledWith(mockUserId, mockUserKey);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
});
});
it("handles linux case", async () => {
@@ -553,6 +805,57 @@ describe("SettingsComponent", () => {
});
});
describe("updateRequireMasterPasswordOnAppRestartHandler", () => {
beforeEach(() => {
jest.clearAllMocks();
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => {
const error = new Error("Test error");
jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error);
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId);
expect(logService.error).toHaveBeenCalled();
expect(validationService.showError).toHaveBeenCalledWith(error);
});
describe("when updating to true", () => {
it("calls the biometrics service to clear and reset biometric key", async () => {
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
);
expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
});
});
describe("when updating to false", () => {
it("doesn't enroll persistent biometric if already enrolled", async () => {
biometricStateService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
});
});
});
describe("saveVaultTimeout", () => {
const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123;
const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock;

View File

@@ -142,6 +142,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
userHasPinSet: boolean;
pinEnabled$: Observable<boolean> = of(true);
isWindowsV2BiometricsEnabled: boolean = false;
form = this.formBuilder.group({
// Security
@@ -149,6 +150,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
vaultTimeoutAction: [VaultTimeoutAction.Lock],
pin: [null as boolean | null],
biometric: false,
requireMasterPasswordOnAppRestart: true,
autoPromptBiometrics: false,
// Account Preferences
clearClipboard: [null],
@@ -281,6 +283,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
@@ -372,6 +376,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
pin: this.userHasPinSet,
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey(
activeAccount.id,
)),
autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$),
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$),
@@ -479,6 +486,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
)
.subscribe();
this.form.controls.requireMasterPasswordOnAppRestart.valueChanges
.pipe(
concatMap(async (value) => {
await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id);
}),
takeUntil(this.destroy$),
)
.subscribe();
this.form.controls.enableBrowserIntegration.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((enabled) => {
@@ -588,6 +604,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false });
} else {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// 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 &&
!this.userHasMasterPassword
) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(userId);
}
await this.pinService.unsetPin(userId);
}
}
@@ -639,6 +668,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Recommended settings for Windows Hello
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);
}
}
} else if (this.isLinux) {
// Similar to Windows
this.form.controls.autoPromptBiometrics.setValue(false);
@@ -656,6 +695,37 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
}
async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) {
try {
await this.updateRequireMasterPasswordOnAppRestart(enabled, userId);
} catch (error) {
this.logService.error("Error updating require master password on app restart: ", error);
this.validationService.showError(error);
}
}
async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) {
if (enabled) {
// Require master password or PIN on app restart
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
await this.biometricsService.deleteBiometricUnlockKeyForUser(userId);
await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey);
} else {
// Allow biometric unlock on app restart
await this.enrollPersistentBiometricIfNeeded(userId);
}
}
private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise<void> {
if (!(await this.biometricsService.hasPersistentKey(userId))) {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
await this.biometricsService.enrollPersistent(userId, userKey);
this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, {
emitEvent: false,
});
}
}
async updateAutoPromptBiometrics() {
if (this.form.value.autoPromptBiometrics) {
await this.biometricStateService.setPromptAutomatically(true);

View File

@@ -21,6 +21,7 @@ import {
UserLockIcon,
VaultIcon,
LockIcon,
DomainIcon,
} from "@bitwarden/assets/svg";
import {
LoginComponent,
@@ -289,6 +290,8 @@ const routes: Routes = [
pageTitle: {
key: "verifyYourIdentity",
},
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
pageIcon: null,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
@@ -297,12 +300,16 @@ const routes: Routes = [
component: SetInitialPasswordComponent,
data: {
maxWidth: "lg",
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
},
{
path: "change-password",
component: ChangePasswordComponent,
canActivate: [authGuard],
data: {
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
},
{
path: "confirm-key-connector-domain",
@@ -312,6 +319,7 @@ const routes: Routes = [
pageTitle: {
key: "confirmKeyConnectorDomain",
},
pageIcon: DomainIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
],

View File

@@ -28,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype
import { SshAgentService } from "../../autofill/services/ssh-agent.service";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { VersionService } from "../../platform/services/version.service";
import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@Injectable()
@@ -53,6 +54,7 @@ export class InitService {
private autofillService: DesktopAutofillService,
private autotypeService: DesktopAutotypeService,
private sdkLoadService: SdkLoadService,
private biometricMessageHandlerService: BiometricMessageHandlerService,
private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
private readonly migrationRunner: MigrationRunner,
@@ -95,6 +97,7 @@ export class InitService {
const containerService = new ContainerService(this.keyService, this.encryptService);
containerService.attachToGlobal(this.win);
await this.biometricMessageHandlerService.init();
await this.autofillService.init();
await this.autotypeService.init();
};

View File

@@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService {
): Promise<void>;
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
abstract setupBiometrics(): Promise<void>;
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>;
}

View File

@@ -51,6 +51,17 @@ export class MainBiometricsIPCListener {
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
case BiometricAction.GetShouldAutoprompt:
return await this.biometricService.getShouldAutopromptNow();
case BiometricAction.HasPersistentKey:
return await this.biometricService.hasPersistentKey(message.userId as UserId);
case BiometricAction.EnrollPersistent:
return await this.biometricService.enrollPersistent(
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();
default:
return;
}

View File

@@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import {
BiometricsService,
BiometricsStatus,
@@ -16,6 +17,7 @@ import {
import { WindowMain } from "../../main/window.main";
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";
@@ -28,6 +30,13 @@ jest.mock("@bitwarden/desktop-napi", () => {
};
});
jest.mock("./native-v2", () => ({
WindowsBiometricsSystem: jest.fn(),
biometrics_v2: {
initBiometricSystem: jest.fn(),
},
}));
const unlockKey = new SymmetricCryptoKey(new Uint8Array(64));
describe("MainBiometricsService", function () {
@@ -38,24 +47,6 @@ describe("MainBiometricsService", function () {
const cryptoFunctionService = mock<CryptoFunctionService>();
const encryptService = mock<EncryptService>();
it("Should call the platformspecific methods", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const mockService = mock<OsBiometricService>();
(sut as any).osBiometricsService = mockService;
await sut.authenticateBiometric();
expect(mockService.authenticateBiometric).toBeCalled();
});
describe("Should create a platform specific service", function () {
it("Should create a biometrics service specific for Windows", () => {
const sut = new MainBiometricsService(
@@ -207,46 +198,6 @@ describe("MainBiometricsService", function () {
});
});
describe("setupBiometrics", () => {
it("should call the platform specific setup method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.setupBiometrics();
expect(osBiometricsService.runSetup).toHaveBeenCalled();
});
});
describe("authenticateWithBiometrics", () => {
it("should call the platform specific authenticate method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.authenticateWithBiometrics();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
});
describe("unlockWithBiometricsForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
@@ -288,55 +239,6 @@ describe("MainBiometricsService", function () {
});
});
describe("setBiometricProtectedUnlockKeyForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("should call the platform specific setBiometricKey method", async () => {
const userId = "test" as UserId;
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
});
});
describe("deleteBiometricUnlockKeyForUser", () => {
it("should call the platform specific deleteBiometricKey method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
const userId = "test" as UserId;
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
});
});
describe("setShouldAutopromptNow", () => {
let sut: MainBiometricsService;
@@ -386,4 +288,138 @@ describe("MainBiometricsService", function () {
expect(shouldAutoPrompt).toBe(true);
});
});
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;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("calls the platform specific setBiometricKey method", async () => {
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
});
it("calls the platform specific enrollPersistent method", async () => {
await sut.enrollPersistent(userId, unlockKey);
expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey);
});
it("calls the platform specific hasPersistentKey method", async () => {
await sut.hasPersistentKey(userId);
expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId);
});
it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => {
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
});
it("calls the platform specific authenticateWithBiometrics method", async () => {
await sut.authenticateWithBiometrics();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
it("calls the platform specific authenticateBiometric method", async () => {
await sut.authenticateBiometric();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
it("calls the platform specific setupBiometrics method", async () => {
await sut.setupBiometrics();
expect(osBiometricsService.runSetup).toHaveBeenCalled();
});
});
});

View File

@@ -10,17 +10,19 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private shouldAutoPrompt = true;
private windowsV2BiometricsEnabled = false;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
platform: NodeJS.Platform,
private platform: NodeJS.Platform,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
@@ -144,4 +146,28 @@ export class MainBiometricsService extends DesktopBiometricsService {
async canEnableBiometricUnlock(): Promise<boolean> {
return true;
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await this.osBiometricsService.enrollPersistent(userId, key);
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
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;
}
}

View File

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

View File

@@ -0,0 +1,126 @@
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2 } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics_v2: {
initBiometricSystem: jest.fn(() => "mockSystem"),
provideKey: jest.fn(),
enrollPersistent: jest.fn(),
unenroll: jest.fn(),
unlock: jest.fn(),
authenticate: jest.fn(),
authenticateAvailable: jest.fn(),
unlockAvailable: jest.fn(),
hasPersistent: jest.fn(),
},
passwords: {
isAvailable: jest.fn(),
},
}));
const mockKey = new Uint8Array(64);
jest.mock("../../../utils", () => ({
isFlatpak: jest.fn(() => false),
isLinux: jest.fn(() => true),
isSnapStore: jest.fn(() => false),
}));
describe("OsBiometricsServiceWindows", () => {
const userId = "user-id" as UserId;
let service: OsBiometricsServiceWindows;
let i18nService: I18nService;
let windowMain: WindowMain;
let logService: LogService;
beforeEach(() => {
i18nService = mock<I18nService>();
windowMain = mock<WindowMain>();
logService = mock<LogService>();
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4]));
service = new OsBiometricsServiceWindows(i18nService, windowMain, logService);
});
it("should enroll persistent biometric key", async () => {
await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey));
expect(biometrics_v2.enrollPersistent).toHaveBeenCalled();
});
it("should set biometric key", async () => {
await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey));
expect(biometrics_v2.provideKey).toHaveBeenCalled();
});
it("should delete biometric key", async () => {
await service.deleteBiometricKey(userId);
expect(biometrics_v2.unenroll).toHaveBeenCalled();
});
it("should get biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey);
const result = await service.getBiometricKey(userId);
expect(result).toBeInstanceOf(SymmetricCryptoKey);
});
it("should return null if no biometric key", async () => {
const error = new Error("No key found");
(biometrics_v2.unlock as jest.Mock).mockRejectedValue(error);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
expect(logService.warning).toHaveBeenCalledWith(
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
);
});
it("should authenticate biometric", async () => {
(biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true);
const result = await service.authenticateBiometric();
expect(result).toBe(true);
});
it("should check if biometrics is supported", async () => {
(biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should return needs setup false", async () => {
const result = await service.needsSetup();
expect(result).toBe(false);
});
it("should return auto setup false", async () => {
const result = await service.canAutoSetup();
expect(result).toBe(false);
});
it("should get biometrics first unlock status for user", async () => {
(biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return false for hasPersistentKey false", async () => {
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false);
const result = await service.hasPersistentKey(userId);
expect(result).toBe(false);
});
it("should return false for hasPersistentKey true", async () => {
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true);
const result = await service.hasPersistentKey(userId);
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2 } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../../main/window.main";
import { OsBiometricService } from "../os-biometrics.service";
export default class OsBiometricsServiceWindows implements OsBiometricService {
private biometricsSystem: biometrics_v2.BiometricLockSystem;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
) {
this.biometricsSystem = biometrics_v2.initBiometricSystem();
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.enrollPersistent(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return await biometrics_v2.hasPersistent(this.biometricsSystem, userId);
}
async supportsBiometrics(): Promise<boolean> {
return await biometrics_v2.authenticateAvailable(this.biometricsSystem);
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
try {
const key = await biometrics_v2.unlock(
this.biometricsSystem,
userId,
this.windowMain.win.getNativeWindowHandle(),
);
return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null;
} catch (error) {
this.logService.warning(
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
);
return null;
}
}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
await biometrics_v2.unenroll(this.biometricsSystem, userId);
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics_v2.authenticate(
this.biometricsSystem,
hwnd,
this.i18nService.t("windowsHelloConsentMessage"),
);
}
async needsSetup() {
return false;
}
async canAutoSetup(): Promise<boolean> {
return false;
}
async runSetup(): Promise<void> {}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) ||
(await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
}

View File

@@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
private logService: LogService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;

View File

@@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService {
private logService: LogService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64());
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null;
}
async supportsBiometrics(): Promise<boolean> {
return systemPreferences.canPromptTouchID();
}

View File

@@ -35,6 +35,12 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
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();
}

View File

@@ -25,4 +25,6 @@ export interface OsBiometricService {
setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
deleteBiometricKey(userId: UserId): Promise<void>;
getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus>;
enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
hasPersistentKey(userId: UserId): Promise<boolean>;
}

View File

@@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService {
BiometricsStatus.ManualSetupNeeded,
].includes(biometricStatus);
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64());
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
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();
}
}

View File

@@ -50,6 +50,25 @@ const biometric = {
action: BiometricAction.SetShouldAutoprompt,
data: should,
} satisfies BiometricMessage),
enrollPersistent: (userId: string, keyB64: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnrollPersistent,
userId: userId,
key: keyB64,
} satisfies BiometricMessage),
hasPersistentKey: (userId: string): Promise<boolean> =>
ipcRenderer.invoke("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),
};
export default {

View File

@@ -1852,6 +1852,12 @@
"lockWithMasterPassOnRestart1": {
"message": "Lock with master password on restart"
},
"requireMasterPasswordOrPinOnAppRestart": {
"message": "Require master password or PIN on app restart"
},
"requireMasterPasswordOnAppRestart": {
"message": "Require master password on app restart"
},
"deleteAccount": {
"message": "Delete account"
},

View File

@@ -82,7 +82,12 @@ export class WindowMain {
ipcMain.on("window-hide", () => {
if (this.win != null) {
this.win.hide();
if (isWindows()) {
// On windows, to return focus we need minimize
this.win.minimize();
} else {
this.win.hide();
}
}
});

View File

@@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, I18nMockService } from "@bitwarden/components";
import {
KeyService,
BiometricsService,
BiometricStateService,
BiometricsCommands,
} from "@bitwarden/key-management";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management";
import { ConfigService } from "@bitwarden/services/config.service";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let logService: MockProxy<LogService>;
let configService: MockProxy<ConfigService>;
let messagingService: MockProxy<MessagingService>;
let desktopSettingsService: DesktopSettingsService;
let biometricStateService: BiometricStateService;
let biometricsService: MockProxy<BiometricsService>;
let dialogService: MockProxy<DialogService>;
let accountService: AccountService;
let authService: MockProxy<AuthService>;
let ngZone: MockProxy<NgZone>;
let i18nService: MockProxy<I18nMockService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => {
logService = mock<LogService>();
messagingService = mock<MessagingService>();
desktopSettingsService = mock<DesktopSettingsService>();
biometricStateService = mock<BiometricStateService>();
configService = mock<ConfigService>();
biometricsService = mock<BiometricsService>();
dialogService = mock<DialogService>();
accountService = new FakeAccountService(accounts);
authService = mock<AuthService>();
ngZone = mock<NgZone>();
i18nService = mock<I18nMockService>();
desktopSettingsService.browserIntegrationEnabled$ = of(false);
desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false);
@@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => {
cryptoFunctionService.rsaEncrypt.mockResolvedValue(
Utils.fromUtf8ToArray("encrypted") as CsprngArray,
);
configService.getFeatureFlag.mockResolvedValue(false);
service = new BiometricMessageHandlerService(
cryptoFunctionService,
keyService,
@@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => {
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
i18nService,
configService,
);
});
@@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => {
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
i18nService,
configService,
);
});
@@ -511,4 +503,19 @@ describe("BiometricMessageHandlerService", () => {
},
);
});
describe("init", () => {
it("enables Windows v2 biometrics when feature flag enabled", async () => {
configService.getFeatureFlag.mockReturnValue(true);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled();
});
it("does not enable Windows v2 biometrics when feature flag disabled", async () => {
configService.getFeatureFlag.mockReturnValue(false);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { DialogService } from "@bitwarden/components";
import {
BiometricStateService,
BiometricsCommands,
BiometricsService,
BiometricsStatus,
KeyService,
} from "@bitwarden/key-management";
import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management";
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service";
import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -82,13 +78,12 @@ export class BiometricMessageHandlerService {
private logService: LogService,
private messagingService: MessagingService,
private desktopSettingService: DesktopSettingsService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private biometricsService: DesktopBiometricsService,
private dialogService: DialogService,
private accountService: AccountService,
private authService: AuthService,
private ngZone: NgZone,
private i18nService: I18nService,
private configService: ConfigService,
) {
combineLatest([
this.desktopSettingService.browserIntegrationEnabled$,
@@ -119,6 +114,19 @@ export class BiometricMessageHandlerService {
private connectedApps: ConnectedApps = new ConnectedApps();
async init() {
this.logService.debug(
"[BiometricMessageHandlerService] Initializing biometric message handler",
);
const windowsV2Enabled = await this.configService.getFeatureFlag(
FeatureFlag.WindowsBiometricsV2,
);
if (windowsV2Enabled) {
await this.biometricsService.enableWindowsV2Biometrics();
}
}
async handleMessage(msg: LegacyMessageWrapper) {
const { appId, message: rawMessage } = msg as LegacyMessageWrapper;

View File

@@ -13,6 +13,12 @@ export enum BiometricAction {
GetShouldAutoprompt = "getShouldAutoprompt",
SetShouldAutoprompt = "setShouldAutoprompt",
EnrollPersistent = "enrollPersistent",
HasPersistentKey = "hasPersistentKey",
EnableWindowsV2 = "enableWindowsV2",
IsWindowsV2Enabled = "isWindowsV2Enabled",
}
export type BiometricMessage =
@@ -22,7 +28,15 @@ export type BiometricMessage =
key: string;
}
| {
action: Exclude<BiometricAction, BiometricAction.SetKeyForUser>;
action: BiometricAction.EnrollPersistent;
userId: string;
key: string;
}
| {
action: Exclude<
BiometricAction,
BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent
>;
userId?: string;
data?: any;
};

View File

@@ -24,6 +24,11 @@ import {
SsoKeyIcon,
LockIcon,
BrowserExtensionIcon,
ActiveSendIcon,
TwoFactorAuthAuthenticatorIcon,
AccountWarning,
BusinessWelcome,
DomainIcon,
} from "@bitwarden/assets/svg";
import {
PasswordHintComponent,
@@ -295,6 +300,7 @@ const routes: Routes = [
key: "viewSend",
},
showReadonlyHostname: true,
pageIcon: ActiveSendIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
@@ -314,6 +320,7 @@ const routes: Routes = [
component: SetInitialPasswordComponent,
data: {
maxWidth: "lg",
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
},
{
@@ -379,6 +386,8 @@ const routes: Routes = [
pageTitle: {
key: "verifyYourIdentity",
},
// `TwoFactorAuthComponent` manually sets its icon based on the 2fa type
pageIcon: null,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
@@ -439,6 +448,7 @@ const routes: Routes = [
key: "recoverAccountTwoStep",
},
titleId: "recoverAccountTwoStep",
pageIcon: TwoFactorAuthAuthenticatorIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
@@ -469,6 +479,7 @@ const routes: Routes = [
},
titleId: "acceptEmergency",
doNotSaveUrl: false,
pageIcon: VaultIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
@@ -488,6 +499,7 @@ const routes: Routes = [
key: "deleteAccount",
},
titleId: "deleteAccount",
pageIcon: AccountWarning,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
@@ -509,6 +521,7 @@ const routes: Routes = [
key: "deleteAccount",
},
titleId: "deleteAccount",
pageIcon: AccountWarning,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
@@ -526,6 +539,7 @@ const routes: Routes = [
key: "removeMasterPassword",
},
titleId: "removeMasterPassword",
pageIcon: LockIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
@@ -537,6 +551,7 @@ const routes: Routes = [
key: "confirmKeyConnectorDomain",
},
titleId: "confirmKeyConnectorDomain",
pageIcon: DomainIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
@@ -548,6 +563,7 @@ const routes: Routes = [
},
data: {
maxWidth: "3xl",
pageIcon: BusinessWelcome,
} satisfies AnonLayoutWrapperData,
},
{
@@ -559,6 +575,7 @@ const routes: Routes = [
},
data: {
maxWidth: "3xl",
pageIcon: BusinessWelcome,
} satisfies AnonLayoutWrapperData,
},
{
@@ -582,12 +599,15 @@ const routes: Routes = [
path: "change-password",
component: ChangePasswordComponent,
canActivate: [authGuard],
data: {
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
},
{
path: "setup-extension",
data: {
hideCardWrapper: true,
hideIcon: true,
pageIcon: null,
maxWidth: "4xl",
} satisfies AnonLayoutWrapperData,
children: [

View File

@@ -11,12 +11,12 @@
(setPasswordEvent)="setPassword($event)"
*ngIf="passwordRequired && !error"
></app-send-access-password>
<bit-no-items [icon]="sendIcon" class="tw-text-main" *ngIf="unavailable">
<ng-container slot="description">{{ "sendAccessUnavailable" | i18n }}</ng-container>
</bit-no-items>
<bit-no-items [icon]="sendIcon" class="tw-text-main" *ngIf="error">
<ng-container slot="description">{{ "unexpectedErrorSend" | i18n }}</ng-container>
</bit-no-items>
<div class="tw-text-main tw-text-center" *ngIf="unavailable">
<p bitTypography="body1">{{ "sendAccessUnavailable" | i18n }}</p>
</div>
<div class="tw-text-main tw-text-center" *ngIf="error">
<p bitTypography="body1">{{ "unexpectedErrorSend" | i18n }}</p>
</div>
<div *ngIf="!passwordRequired && send && !error && !unavailable">
<p class="tw-text-center">
<b>{{ send.name }}</b>

View File

@@ -4,7 +4,6 @@ import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { ActiveSendIcon } from "@bitwarden/assets/svg";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -17,7 +16,7 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { AnonLayoutWrapperDataService, NoItemsModule, ToastService } from "@bitwarden/components";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
@@ -34,7 +33,6 @@ import { SendAccessTextComponent } from "./send-access-text.component";
SendAccessTextComponent,
SendAccessPasswordComponent,
SharedModule,
NoItemsModule,
],
})
export class AccessComponent implements OnInit {
@@ -49,7 +47,6 @@ export class AccessComponent implements OnInit {
protected hideEmail = false;
protected decKey: SymmetricCryptoKey;
protected accessRequest: SendAccessRequest;
protected sendIcon = ActiveSendIcon;
protected formGroup = this.formBuilder.group({});

View File

@@ -138,7 +138,6 @@ describe("SetupExtensionComponent", () => {
key: "somethingWentWrong",
},
pageIcon: BrowserExtensionIcon,
hideIcon: false,
hideCardWrapper: false,
maxWidth: "md",
});

View File

@@ -164,7 +164,6 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
key: "somethingWentWrong",
},
pageIcon: BrowserExtensionIcon,
hideIcon: false,
hideCardWrapper: false,
maxWidth: "md",
});

View File

@@ -0,0 +1,18 @@
import { svgIcon } from "../icon-service";
export const AccountWarning = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 12.75 96 72">
<path class="tw-fill-illustration-bg-primary" d="M0 18.512a5.76 5.76 0 0 1 5.76-5.76h54.48a5.76 5.76 0 0 1 5.76 5.76v38.48a5.76 5.76 0 0 1-5.76 5.76H5.76A5.76 5.76 0 0 1 0 56.992v-38.48Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M60.24 14.672H5.76a3.84 3.84 0 0 0-3.84 3.84v38.48a3.84 3.84 0 0 0 3.84 3.84h54.48a3.84 3.84 0 0 0 3.84-3.84v-38.48a3.84 3.84 0 0 0-3.84-3.84Zm-54.48-1.92A5.76 5.76 0 0 0 0 18.512v38.48a5.76 5.76 0 0 0 5.76 5.76h54.48a5.76 5.76 0 0 0 5.76-5.76v-38.48a5.76 5.76 0 0 0-5.76-5.76H5.76Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M65 24.712H1v-1.92h64v1.92Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-outline" d="M62 18.752a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM56 18.752a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM50 18.752a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
<path class="tw-fill-illustration-tertiary" d="M21.099 48.635c1.515-2.606 5.28-2.606 6.795 0l10.053 17.29c1.523 2.62-.367 5.906-3.398 5.906H14.444c-3.03 0-4.92-3.285-3.398-5.905L21.1 48.636Z"/>
<path class="tw-fill-illustration-outline" d="M20.373 48.213c1.839-3.163 6.408-3.163 8.247 0l10.053 17.29c1.849 3.18-.446 7.168-4.124 7.168H14.444c-3.678 0-5.973-3.987-4.124-7.167l10.053-17.29Zm6.795.844c-1.192-2.049-4.152-2.049-5.343 0L11.773 66.349c-1.198 2.06.288 4.643 2.671 4.643h20.105c2.383 0 3.869-2.583 2.671-4.643l-10.052-17.29Z"/>
<circle class="tw-fill-illustration-outline" cx="24.496" cy="66.428" r="1.351" />
<path class="tw-fill-illustration-outline" d="M22.517 54.707a.393.393 0 0 1 .39-.434h3.178c.234 0 .416.202.391.434l-.929 8.67a.393.393 0 0 1-.39.35h-1.32c-.201 0-.37-.151-.391-.35L22.977 59l-.46-4.293Z"/>
<path class="tw-fill-illustration-bg-tertiary" d="M82 42.752c0 7.732-6.268 14-14 14s-14-6.268-14-14 6.268-14 14-14 14 6.268 14 14Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M68 54.832c6.671 0 12.08-5.408 12.08-12.08S74.67 30.672 68 30.672c-6.672 0-12.08 5.408-12.08 12.08s5.408 12.08 12.08 12.08Zm0 1.92c7.732 0 14-6.268 14-14s-6.268-14-14-14-14 6.268-14 14 6.268 14 14 14Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M93.907 72.728A27.5 27.5 0 0 1 96 83.252a1.5 1.5 0 0 1-1.5 1.5h-52a1.5 1.5 0 0 1-1.5-1.5 27.5 27.5 0 0 1 52.907-10.524Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M94.076 82.832a25.58 25.58 0 0 0-51.153 0h51.153Zm1.924.42a27.5 27.5 0 1 0-55 0 1.5 1.5 0 0 0 1.5 1.5h52a1.5 1.5 0 0 0 1.5-1.5Z" clip-rule="evenodd"/>
</svg>
`;

View File

@@ -0,0 +1,32 @@
import { svgIcon } from "../icon-service";
export const BusinessWelcome = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="4.17 0.75 91.67 95.83">
<path class="tw-fill-illustration-bg-secondary" d="M56.25 9.085A8.333 8.333 0 0 1 64.583.752h8.334a8.333 8.333 0 0 1 8.333 8.333v4.167h-25V9.085Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M72.917 2.835h-8.334a6.25 6.25 0 0 0-6.25 6.25v2.084h20.834V9.085a6.25 6.25 0 0 0-6.25-6.25ZM64.583.752a8.333 8.333 0 0 0-8.333 8.333v4.167h25V9.085A8.333 8.333 0 0 0 72.917.752h-8.334Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-secondary" d="M48.959 17.418a8.333 8.333 0 0 1 8.333-8.333h22.916a8.333 8.333 0 0 1 8.334 8.333v6.25H48.958v-6.25Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M80.209 11.168H57.291a6.25 6.25 0 0 0-6.25 6.25v4.167h35.416v-4.167a6.25 6.25 0 0 0-6.25-6.25ZM57.291 9.085a8.333 8.333 0 0 0-8.334 8.333v6.25h39.584v-6.25a8.333 8.333 0 0 0-8.334-8.333H57.293Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-secondary" d="M41.666 27.835A8.333 8.333 0 0 1 50 19.502h37.5a8.333 8.333 0 0 1 8.333 8.333v66.667c0 1.15-.933 2.083-2.083 2.083H41.666v-68.75Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M87.5 21.585H50a6.25 6.25 0 0 0-6.25 6.25v66.667h50V27.835a6.25 6.25 0 0 0-6.25-6.25ZM50 19.502a8.333 8.333 0 0 0-8.334 8.333v68.75H93.75c1.15 0 2.083-.932 2.083-2.083V27.835a8.333 8.333 0 0 0-8.333-8.333H50Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-primary"" d="M4.167 46.585a8.333 8.333 0 0 1 8.333-8.333h39.583a8.333 8.333 0 0 1 8.334 8.333v47.917c0 1.15-.933 2.083-2.084 2.083H6.25a2.083 2.083 0 0 1-2.083-2.083V46.585Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M52.083 40.335H12.5a6.25 6.25 0 0 0-6.25 6.25v47.917h52.083V46.585a6.25 6.25 0 0 0-6.25-6.25ZM12.5 38.252a8.333 8.333 0 0 0-8.334 8.333v47.917c0 1.15.933 2.083 2.084 2.083h52.083c1.15 0 2.084-.932 2.084-2.083V46.585a8.333 8.333 0 0 0-8.334-8.333H12.5Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-tertiary" d="M21.875 84.085a8.333 8.333 0 0 1 8.333-8.333h4.167a8.333 8.333 0 0 1 8.333 8.333v12.5H21.875v-12.5Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M34.375 77.835h-4.167a6.25 6.25 0 0 0-6.25 6.25v10.417h16.667V84.085a6.25 6.25 0 0 0-6.25-6.25Zm-4.167-2.083a8.333 8.333 0 0 0-8.333 8.333v12.5h20.833v-12.5a8.333 8.333 0 0 0-8.333-8.333h-4.167Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M23.959 85.127a8.333 8.333 0 0 0-8.334-8.333h-1.041c-.576 0-1.042.466-1.042 1.042v18.75h10.417V85.127Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M15.625 94.502V78.877a6.25 6.25 0 0 1 6.25 6.25v9.375h-6.25Zm0-17.708a8.333 8.333 0 0 1 8.334 8.333v11.459H13.542v-18.75c0-.576.466-1.042 1.042-1.042h1.041Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M40.625 85.127a8.333 8.333 0 0 1 8.333-8.333H50c.575 0 1.042.466 1.042 1.042v18.75H40.625V85.127Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M48.958 94.502V78.877a6.25 6.25 0 0 0-6.25 6.25v9.375h6.25Zm0-17.708a8.333 8.333 0 0 0-8.333 8.333v11.459h10.417v-18.75c0-.576-.467-1.042-1.042-1.042h-1.042Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M13.541 48.669c0-.576.467-1.042 1.042-1.042h8.333c.576 0 1.042.466 1.042 1.042v8.333c0 .575-.466 1.042-1.041 1.042h-8.334a1.042 1.042 0 0 1-1.042-1.042v-8.333Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M15.625 49.71v6.25h6.25v-6.25h-6.25Zm-1.042-2.083c-.575 0-1.042.466-1.042 1.042v8.333c0 .575.467 1.042 1.042 1.042h8.333c.576 0 1.042-.467 1.042-1.042v-8.333c0-.576-.466-1.042-1.041-1.042h-8.334Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M13.541 62.21c0-.575.467-1.041 1.042-1.041h8.333c.576 0 1.042.466 1.042 1.042v8.333c0 .575-.466 1.042-1.041 1.042h-8.334a1.042 1.042 0 0 1-1.042-1.042V62.21Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M15.625 63.252v6.25h6.25v-6.25h-6.25Zm-1.042-2.083c-.575 0-1.042.466-1.042 1.042v8.333c0 .575.467 1.042 1.042 1.042h8.333c.576 0 1.042-.467 1.042-1.042V62.21c0-.576-.466-1.042-1.041-1.042h-8.334Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M27.084 48.669c0-.576.466-1.042 1.041-1.042h8.334c.575 0 1.041.466 1.041 1.042v8.333c0 .575-.466 1.042-1.041 1.042h-8.334a1.042 1.042 0 0 1-1.041-1.042v-8.333Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M29.167 49.71v6.25h6.25v-6.25h-6.25Zm-1.042-2.083c-.575 0-1.041.466-1.041 1.042v8.333c0 .575.466 1.042 1.041 1.042h8.334c.575 0 1.041-.467 1.041-1.042v-8.333c0-.576-.466-1.042-1.041-1.042h-8.334Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M27.084 62.21c0-.575.466-1.041 1.041-1.041h8.334c.575 0 1.041.466 1.041 1.042v8.333c0 .575-.466 1.042-1.041 1.042h-8.334a1.042 1.042 0 0 1-1.041-1.042V62.21Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M29.167 63.252v6.25h6.25v-6.25h-6.25Zm-1.042-2.083c-.575 0-1.041.466-1.041 1.042v8.333c0 .575.466 1.042 1.041 1.042h8.334c.575 0 1.041-.467 1.041-1.042V62.21c0-.576-.466-1.042-1.041-1.042h-8.334Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M40.625 48.669c0-.576.466-1.042 1.042-1.042H50c.575 0 1.042.466 1.042 1.042v8.333c0 .575-.467 1.042-1.042 1.042h-8.333a1.042 1.042 0 0 1-1.042-1.042v-8.333Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M42.708 49.71v6.25h6.25v-6.25h-6.25Zm-1.041-2.083c-.576 0-1.042.466-1.042 1.042v8.333c0 .575.466 1.042 1.042 1.042H50c.575 0 1.042-.467 1.042-1.042v-8.333c0-.576-.467-1.042-1.042-1.042h-8.333ZM48.959 28.877c0-.576.466-1.042 1.041-1.042h6.25a1.042 1.042 0 1 1 0 2.083H50a1.042 1.042 0 0 1-1.041-1.041Zm11.458-1.042a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm9.375 1.042c0-.576.466-1.042 1.041-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm10.416 0c0-.576.467-1.042 1.042-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm0 6.25c0-.576.467-1.042 1.042-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm1.042 5.208a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-1.041 7.292c0-.576.466-1.042 1.041-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm1.041 5.208a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-10.416-18.75a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-1.042 7.292c0-.576.466-1.042 1.041-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm1.041 5.208a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-1.041 7.292c0-.576.466-1.042 1.041-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.041-1.041Zm-9.375-19.792a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-11.459 1.042c0-.576.467-1.042 1.042-1.042h6.25a1.042 1.042 0 1 1 0 2.083H50a1.042 1.042 0 0 1-1.041-1.041Zm11.459 5.208a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Zm-1.042 7.292c0-.576.467-1.042 1.042-1.042h6.25a1.042 1.042 0 1 1 0 2.083h-6.25a1.042 1.042 0 0 1-1.042-1.041Zm1.042 5.208a1.042 1.042 0 1 0 0 2.083h6.25a1.042 1.042 0 0 0 0-2.083h-6.25Z" clip-rule="evenodd"/>
<path class="tw-fill-illustration-bg-tertiary" d="M40.625 62.21c0-.575.466-1.041 1.042-1.041H50c.575 0 1.042.466 1.042 1.042v8.333c0 .575-.467 1.042-1.042 1.042h-8.333a1.042 1.042 0 0 1-1.042-1.042V62.21Z"/>
<path class="tw-fill-illustration-outline" fill-rule="evenodd" d="M42.708 63.252v6.25h6.25v-6.25h-6.25Zm-1.041-2.083c-.576 0-1.042.466-1.042 1.042v8.333c0 .575.466 1.042 1.042 1.042H50c.575 0 1.042-.467 1.042-1.042V62.21c0-.576-.467-1.042-1.042-1.042h-8.333Z" clip-rule="evenodd"/>
</svg>
`;

View File

@@ -1,3 +1,4 @@
export * from "./account-warning.icon";
export * from "./active-send.icon";
export { default as AdminConsoleLogo } from "./admin-console";
export * from "./background-left-illustration";
@@ -6,6 +7,7 @@ export * from "./bitwarden-icon";
export * from "./bitwarden-logo.icon";
export * from "./browser-extension";
export { default as BusinessUnitPortalLogo } from "./business-unit-portal";
export * from "./business-welcome.icon";
export * from "./carousel-icon";
export * from "./credit-card.icon";
export * from "./deactivated-org";

View File

@@ -1,14 +1,5 @@
import { svgIcon } from "../icon-service";
/**
* Shield logo with extra space in the viewbox.
*/
const AnonLayoutBitwardenShield = svgIcon`
<svg viewBox="10 15 100 100" xmlns="http://www.w3.org/2000/svg">
<path class="tw-fill-marketing-logo" d="M82.2944 69.1899V37.2898H60V93.9624C63.948 91.869 67.4812 89.5927 70.5998 87.1338C78.3962 81.0196 82.2944 75.0383 82.2944 69.1899ZM91.8491 30.9097V69.1899C91.8491 72.0477 91.2934 74.8805 90.182 77.6883C89.0706 80.4962 87.6938 82.9884 86.0516 85.1649C84.4094 87.3415 82.452 89.4598 80.1794 91.5201C77.9068 93.5803 75.8084 95.2916 73.8842 96.654C71.96 98.0164 69.9528 99.304 67.8627 100.517C65.7726 101.73 64.288 102.552 63.4088 102.984C62.5297 103.416 61.8247 103.748 61.2939 103.981C60.8958 104.18 60.4645 104.28 60 104.28C59.5355 104.28 59.1042 104.18 58.7061 103.981C58.1753 103.748 57.4703 103.416 56.5911 102.984C55.712 102.552 54.2273 101.73 52.1372 100.517C50.0471 99.304 48.04 98.0164 46.1158 96.654C44.1916 95.2916 42.0932 93.5803 39.8206 91.5201C37.548 89.4598 35.5906 87.3415 33.9484 85.1649C32.3062 82.9884 30.9294 80.4962 29.818 77.6883C28.7066 74.8805 28.1509 72.0477 28.1509 69.1899V30.9097C28.1509 30.0458 28.4661 29.2981 29.0964 28.6668C29.7267 28.0354 30.4732 27.7197 31.3358 27.7197H88.6642C89.5268 27.7197 90.2732 28.0354 90.9036 28.6668C91.5339 29.2981 91.8491 30.0458 91.8491 30.9097Z" />
</svg>
`;
const BitwardenShield = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 32" fill="none">
<g clip-path="url(#bitwarden-shield-clip)">
@@ -22,4 +13,4 @@ const BitwardenShield = svgIcon`
</svg>
`;
export { AnonLayoutBitwardenShield, BitwardenShield };
export { BitwardenShield };

View File

@@ -35,6 +35,7 @@ export enum FeatureFlag {
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
/* Tools */
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.WindowsBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
/* Platform */

View File

@@ -1,6 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class VerifyBankRequest {
amount1: number;
amount2: number;
}

View File

@@ -11,10 +11,10 @@ export abstract class AnonLayoutWrapperDataService {
*
* @param data - The data to set on the AnonLayoutWrapperComponent to feed into the AnonLayoutComponent.
*/
abstract setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void;
abstract setAnonLayoutWrapperData(data: Partial<AnonLayoutWrapperData>): void;
/**
* Reactively gets the current AnonLayoutWrapperData.
*/
abstract anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData>;
abstract anonLayoutWrapperData$(): Observable<Partial<AnonLayoutWrapperData>>;
}

View File

@@ -5,7 +5,6 @@
[showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
[hideCardWrapper]="hideCardWrapper"
[hideIcon]="hideIcon"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<router-outlet></router-outlet>

View File

@@ -25,13 +25,9 @@ export interface AnonLayoutWrapperData {
*/
pageSubtitle?: string | Translation | null;
/**
* The optional icon to display on the page.
* The icon to display on the page. Pass null to hide the icon.
*/
pageIcon?: Icon | null;
/**
* Hides the default Bitwarden shield icon.
*/
hideIcon?: boolean;
pageIcon: Icon | null;
/**
* Optional flag to either show the optional environment selector (false) or just a readonly hostname (true).
*/
@@ -59,11 +55,10 @@ export class AnonLayoutWrapperComponent implements OnInit {
protected pageTitle?: string | null;
protected pageSubtitle?: string | null;
protected pageIcon?: Icon | null;
protected pageIcon: Icon | null = null;
protected showReadonlyHostname?: boolean | null;
protected maxWidth?: AnonLayoutMaxWidth | null;
protected hideCardWrapper?: boolean | null;
protected hideIcon?: boolean | null;
protected hideBackgroundIllustration?: boolean | null;
constructor(
@@ -115,10 +110,6 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.pageIcon = firstChildRouteData["pageIcon"];
}
if (firstChildRouteData["hideIcon"] !== undefined) {
this.hideIcon = firstChildRouteData["hideIcon"];
}
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"];
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
@@ -129,12 +120,12 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.anonLayoutWrapperDataService
.anonLayoutWrapperData$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: AnonLayoutWrapperData) => {
.subscribe((data: Partial<AnonLayoutWrapperData>) => {
this.setAnonLayoutWrapperData(data);
});
}
private setAnonLayoutWrapperData(data: AnonLayoutWrapperData) {
private setAnonLayoutWrapperData(data: Partial<AnonLayoutWrapperData>) {
if (!data) {
return;
}
@@ -166,11 +157,6 @@ export class AnonLayoutWrapperComponent implements OnInit {
if (data.hideBackgroundIllustration !== undefined) {
this.hideBackgroundIllustration = data.hideBackgroundIllustration;
}
if (data.hideIcon !== undefined) {
this.hideIcon = data.hideIcon;
}
if (data.maxWidth !== undefined) {
this.maxWidth = data.maxWidth;
}
@@ -197,7 +183,6 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.showReadonlyHostname = null;
this.maxWidth = null;
this.hideCardWrapper = null;
this.hideIcon = null;
this.hideBackgroundIllustration = null;
}
}

View File

@@ -147,7 +147,9 @@ export const DefaultContentExample: Story = {
children: [
{
path: "default-example",
data: {},
data: {
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",

View File

@@ -14,13 +14,15 @@
</a>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
@let iconInput = icon();
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
*ngIf="!hideIcon()"
*ngIf="iconInput !== null"
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="icon()"></bit-icon>
<bit-icon [icon]="iconInput"></bit-icon>
</div>
<ng-container *ngIf="title()">

View File

@@ -12,7 +12,6 @@ import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
AnonLayoutBitwardenShield,
BackgroundLeftIllustration,
BackgroundRightIllustration,
BitwardenLogo,
@@ -45,11 +44,10 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
readonly title = input<string>();
readonly subtitle = input<string>();
readonly icon = model<Icon>();
readonly icon = model.required<Icon | null>();
readonly showReadonlyHostname = input<boolean>(false);
readonly hideLogo = input<boolean>(false);
readonly hideFooter = input<boolean>(false);
readonly hideIcon = input<boolean>(false);
readonly hideCardWrapper = input<boolean>(false);
readonly hideBackgroundIllustration = input<boolean>(false);
@@ -99,11 +97,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
this.maxWidth.set(this.maxWidth() ?? "md");
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
this.version = await this.platformUtilsService.getApplicationVersion();
// If there is no icon input, then use the default icon
if (this.icon() == null) {
this.icon.set(AnonLayoutBitwardenShield);
}
}
async ngOnChanges(changes: SimpleChanges) {

View File

@@ -62,12 +62,8 @@ export default {
}),
],
render: (args) => {
const { useDefaultIcon, icon, ...rest } = args;
return {
props: {
...rest,
icon: useDefaultIcon ? null : icon,
},
props: args,
template: /*html*/ `
<auth-anon-layout
[title]="title"
@@ -76,7 +72,6 @@ export default {
[showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
[hideCardWrapper]="hideCardWrapper"
[hideIcon]="hideIcon"
[hideLogo]="hideLogo"
[hideFooter]="hideFooter"
[hideBackgroundIllustration]="hideBackgroundIllustration"
@@ -110,11 +105,6 @@ export default {
subtitle: { control: "text" },
icon: { control: false, table: { disable: true } },
useDefaultIcon: {
control: false,
table: { disable: true },
description: "If true, passes null so component falls back to its built-in icon",
},
showReadonlyHostname: { control: "boolean" },
maxWidth: {
@@ -123,7 +113,6 @@ export default {
},
hideCardWrapper: { control: "boolean" },
hideIcon: { control: "boolean" },
hideLogo: { control: "boolean" },
hideFooter: { control: "boolean" },
hideBackgroundIllustration: { control: "boolean" },
@@ -144,7 +133,6 @@ export default {
showReadonlyHostname: false,
maxWidth: "md",
hideCardWrapper: false,
hideIcon: false,
hideLogo: false,
hideFooter: false,
hideBackgroundIllustration: false,
@@ -208,12 +196,8 @@ export const NoWrapper: Story = {
args: { hideCardWrapper: true },
};
export const DefaultIcon: Story = {
args: { useDefaultIcon: true },
};
export const NoIcon: Story = {
args: { hideIcon: true },
args: { icon: null },
};
export const NoLogo: Story = {
@@ -238,7 +222,7 @@ export const MinimalState: Story = {
subtitle: undefined,
contentLength: "normal",
hideCardWrapper: true,
hideIcon: true,
icon: null,
hideLogo: true,
hideFooter: true,
hideBackgroundIllustration: true,

View File

@@ -4,13 +4,13 @@ import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service
import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
export class DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService {
protected anonLayoutWrapperDataSubject = new Subject<AnonLayoutWrapperData>();
protected anonLayoutWrapperDataSubject = new Subject<Partial<AnonLayoutWrapperData>>();
setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void {
setAnonLayoutWrapperData(data: Partial<AnonLayoutWrapperData>): void {
this.anonLayoutWrapperDataSubject.next(data);
}
anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData> {
anonLayoutWrapperData$(): Observable<Partial<AnonLayoutWrapperData>> {
return this.anonLayoutWrapperDataSubject.asObservable();
}
}