1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into passkey-window-working

This commit is contained in:
Anders Åberg
2025-03-11 09:25:32 +01:00
2686 changed files with 159985 additions and 85589 deletions

View File

@@ -1,6 +0,0 @@
{
"env": {
"browser": true,
"node": true
}
}

View File

@@ -32,6 +32,8 @@ function log(configObj) {
function loadConfig(configName) {
try {
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require(`./${configName}.json`);
} catch (e) {
if (e instanceof Error && e.code === "MODULE_NOT_FOUND") {

View File

@@ -5,3 +5,4 @@ index.node
npm-debug.log*
*.node
dist
windows_pluginauthenticator_bindings.rs

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,19 @@
[workspace]
resolver = "2"
members = ["napi", "core", "proxy", "macos_provider"]
members = ["napi", "core", "proxy", "macos_provider", "windows-plugin-authenticator"]
[workspace.package]
version = "0.0.0"
license = "GPL-3.0"
edition = "2021"
publish = false
[workspace.dependencies]
anyhow = "=1.0.94"
log = "=0.4.25"
serde = "=1.0.209"
serde_json = "=1.0.127"
tokio = "=1.43.0"
tokio-util = "=0.7.13"
tokio-stream = "=0.1.15"
thiserror = "=1.0.69"

View File

@@ -1,15 +1,12 @@
[package]
edition = "2021"
license = "GPL-3.0"
name = "desktop_core"
version = "0.0.0"
publish = false
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[features]
default = ["sys"]
manual_test = []
sys = [
default = [
"dep:widestring",
"dep:windows",
"dep:core-foundation",
@@ -18,62 +15,60 @@ sys = [
"dep:zbus",
"dep:zbus_polkit",
]
manual_test = []
[dependencies]
aes = "=0.8.4"
anyhow = "=1.0.94"
anyhow = { workspace = true }
arboard = { version = "=3.4.1", default-features = false, features = [
"wayland-data-control",
"wayland-data-control",
] }
argon2 = { version = "=0.5.3", features = ["zeroize"] }
async-stream = "=0.3.6"
base64 = "=0.22.1"
byteorder = "=1.5.0"
cbc = { version = "=0.1.2", features = ["alloc"] }
homedir = "=0.3.4"
libc = "=0.2.162"
pin-project = "=1.1.7"
dirs = "=5.0.1"
pin-project = "=1.1.8"
dirs = "=6.0.0"
futures = "=0.3.31"
interprocess = { version = "=2.2.1", features = ["tokio"] }
log = "=0.4.22"
log = { workspace = true }
rand = "=0.8.5"
retry = "=2.0.0"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
sha2 = "=0.10.8"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false, features = [
"encryption",
"ed25519",
"rsa",
"getrandom",
"encryption",
"ed25519",
"rsa",
"getrandom",
] }
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "23b50e3bbe6d56ef19ab0e98e8bb1462cb6d77ae" }
tokio = { version = "=1.41.1", features = ["io-util", "sync", "macros", "net"] }
tokio-stream = { version = "=0.1.15", features = ["net"] }
tokio-util = { version = "=0.7.12", features = ["codec"] }
thiserror = "=1.0.69"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] }
tokio-stream = { workspace = true, features = ["net"] }
tokio-util = { workspace = true, features = ["codec"] }
thiserror = { workspace = true }
typenum = "=1.17.0"
rand_chacha = "=0.3.1"
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
rsa = "=0.9.6"
ed25519 = { version = "=2.2.3", features = ["pkcs8"] }
sysinfo = { version = "0.32.0", features = ["windows"] }
bytes = "1.9.0"
sysinfo = { version = "0.33.1", features = ["windows"] }
[target.'cfg(windows)'.dependencies]
widestring = { version = "=1.1.0", optional = true }
windows = { version = "=0.58.0", features = [
"Foundation",
"Security_Credentials_UI",
"Security_Cryptography",
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Pipes",
"Foundation",
"Security_Credentials_UI",
"Security_Cryptography",
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_System_Pipes",
], optional = true }
[target.'cfg(windows)'.dev-dependencies]
@@ -81,12 +76,13 @@ keytar = "=0.1.6"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "=0.10.0", optional = true }
security-framework = { version = "=3.0.0", optional = true }
security-framework-sys = { version = "=2.12.0", optional = true }
security-framework = { version = "=3.1.0", optional = true }
security-framework-sys = { version = "=2.13.0", optional = true }
desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = "=0.3.3"
libc = "=0.2.169"
zbus = { version = "=4.4.0", optional = true }
zbus_polkit = { version = "=4.0.0", optional = true }

View File

@@ -1,3 +1,4 @@
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
pub async fn run_command(value: String) -> Result<String> {
pub async fn run_command(_value: String) -> Result<String> {
todo!("Unix does not support autofill");
}

View File

@@ -1,5 +1,5 @@
use anyhow::Result;
pub async fn run_command(value: String) -> Result<String> {
pub async fn run_command(_value: String) -> Result<String> {
todo!("Windows does not support autofill");
}

View File

@@ -1,13 +1,18 @@
use aes::cipher::generic_array::GenericArray;
use anyhow::{anyhow, Result};
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
pub use biometric::Biometric;
#[cfg(target_os = "windows")]
pub mod windows_focus;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};
@@ -41,6 +46,7 @@ pub trait BiometricTrait {
) -> Result<String>;
}
#[allow(unused)]
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
let iv = base64_engine
.decode(iv_b64)?
@@ -52,9 +58,10 @@ fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<Str
Ok(encrypted.to_string())
}
#[allow(unused)]
fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result<String> {
if let CipherString::AesCbc256_B64 { iv, data } = secret {
let decrypted = crypto::decrypt_aes256(&iv, &data, key_material.derive_key()?)?;
let decrypted = crypto::decrypt_aes256(iv, data, key_material.derive_key()?)?;
Ok(String::from_utf8(decrypted)?)
} else {

View File

@@ -33,12 +33,10 @@ impl super::BiometricTrait for Biometric {
.await;
match result {
Ok(result) => {
return Ok(result.is_authorized);
}
Ok(result) => Ok(result.is_authorized),
Err(e) => {
println!("polkit biometric error: {:?}", e);
return Ok(false);
Ok(false)
}
}
}
@@ -52,7 +50,7 @@ impl super::BiometricTrait for Biometric {
return Ok(true);
}
}
return Ok(false);
Ok(false)
}
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
@@ -68,8 +66,8 @@ impl super::BiometricTrait for Biometric {
// so we use a a key derived from the iv. this key is not intended to add any security
// but only a place-holder
let key = Sha256::digest(challenge);
let key_b64 = base64_engine.encode(&key);
let iv_b64 = base64_engine.encode(&challenge);
let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
}
@@ -100,7 +98,7 @@ impl super::BiometricTrait for Biometric {
let encrypted_secret = crate::password::get_password(service, account).await?;
let secret = CipherString::from_str(&encrypted_secret)?;
return Ok(decrypt(&secret, &key_material)?);
decrypt(&secret, &key_material)
}
}

View File

@@ -1,12 +1,15 @@
use std::{ffi::c_void, str::FromStr};
use std::{
ffi::c_void,
str::FromStr,
sync::{atomic::AtomicBool, Arc},
};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use retry::delay::Fixed;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, h, s, HSTRING},
core::{factory, h, HSTRING},
Foundation::IAsyncOperation,
Security::{
Credentials::{
@@ -14,17 +17,7 @@ use windows::{
},
Cryptography::CryptographicBuffer,
},
Win32::{
Foundation::HWND,
System::WinRT::IUserConsentVerifierInterop,
UI::{
Input::KeyboardAndMouse::{
keybd_event, GetAsyncKeyState, SetFocus, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP,
VK_MENU,
},
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
},
},
Win32::{Foundation::HWND, System::WinRT::IUserConsentVerifierInterop},
};
use crate::{
@@ -32,7 +25,10 @@ use crate::{
crypto::CipherString,
};
use super::{decrypt, encrypt};
use super::{
decrypt, encrypt,
windows_focus::{focus_security_prompt, set_focus},
};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
@@ -88,14 +84,14 @@ impl super::BiometricTrait for Biometric {
let bitwarden = h!("Bitwarden");
let result = KeyCredentialManager::RequestCreateAsync(
&bitwarden,
bitwarden,
KeyCredentialCreationOption::FailIfExists,
)?
.get()?;
let result = match result.Status()? {
KeyCredentialStatus::CredentialAlreadyExists => {
KeyCredentialManager::OpenAsync(&bitwarden)?.get()?
KeyCredentialManager::OpenAsync(bitwarden)?.get()?
}
KeyCredentialStatus::Success => result,
_ => return Err(anyhow!("Failed to create key credential")),
@@ -103,8 +99,22 @@ impl super::BiometricTrait for Biometric {
let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?;
let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?;
focus_security_prompt()?;
let signature = async_operation.get()?;
focus_security_prompt();
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let _ = std::thread::spawn(move || loop {
if !done_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
let signature = async_operation.get();
done.store(true, std::sync::atomic::Ordering::Relaxed);
let signature = signature?;
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
@@ -116,8 +126,8 @@ impl super::BiometricTrait for Biometric {
CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?;
let key = Sha256::digest(&*signature_value);
let key_b64 = base64_engine.encode(&key);
let iv_b64 = base64_engine.encode(&challenge);
let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
}
@@ -151,12 +161,12 @@ impl super::BiometricTrait for Biometric {
Ok(secret) => {
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
let secret = decrypt(&secret, &key_material)?;
return Ok(secret);
Ok(secret)
}
Err(_) => {
// If the secret is not a CipherString, it is not encrypted and we can return it
// directly.
return Ok(encrypted_secret);
Ok(encrypted_secret)
}
}
}
@@ -168,57 +178,6 @@ fn random_challenge() -> [u8; 16] {
challenge
}
/// Searches for a window that looks like a security prompt and set it as focused.
///
/// Gives up after 1.5 seconds with a delay of 500ms between each try.
fn focus_security_prompt() -> Result<()> {
unsafe fn try_find_and_set_focus(
class_name: windows::core::PCSTR,
) -> retry::OperationResult<(), ()> {
let hwnd = unsafe { FindWindowA(class_name, None) };
if let Ok(hwnd) = hwnd {
set_focus(hwnd);
return retry::OperationResult::Ok(());
}
retry::OperationResult::Retry(())
}
let class_name = s!("Credential Dialog Xaml Host");
retry::retry_with_index(Fixed::from_millis(500), |current_try| {
if current_try > 3 {
return retry::OperationResult::Err(());
}
unsafe { try_find_and_set_focus(class_name) }
})
.map_err(|_| anyhow!("Failed to find security prompt"))
}
fn set_focus(window: HWND) {
let mut pressed = false;
unsafe {
// Simulate holding down Alt key to bypass windows limitations
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate#return-value
// The most significant bit indicates if the key is currently being pressed. This means the
// value will be negative if the key is pressed.
if GetAsyncKeyState(VK_MENU.0 as i32) >= 0 {
pressed = true;
keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_EXTENDEDKEY, 0);
}
SetForegroundWindow(window);
SetFocus(window);
if pressed {
keybd_event(
VK_MENU.0 as u8,
0,
KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP,
0,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -245,20 +204,21 @@ mod tests {
assert_eq!(iv.len(), 16);
}
#[test]
#[tokio::test]
#[cfg(feature = "manual_test")]
fn test_prompt() {
async fn test_prompt() {
<Biometric as BiometricTrait>::prompt(
vec![0, 0, 0, 0, 0, 0, 0, 0],
String::from("Hello from Rust"),
)
.await
.unwrap();
}
#[test]
#[tokio::test]
#[cfg(feature = "manual_test")]
fn test_available() {
assert!(<Biometric as BiometricTrait>::available().unwrap())
async fn test_available() {
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
}
#[test]
@@ -275,7 +235,7 @@ mod tests {
match secret {
CipherString::AesCbc256_B64 { iv, data: _ } => {
assert_eq!(iv_b64, base64_engine.encode(&iv));
assert_eq!(iv_b64, base64_engine.encode(iv));
}
_ => panic!("Invalid cipher string"),
}

View File

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

View File

@@ -9,13 +9,9 @@ use crate::error::{CryptoError, KdfParamError, Result};
use super::CipherString;
pub fn decrypt_aes256(
iv: &[u8; 16],
data: &Vec<u8>,
key: GenericArray<u8, U32>,
) -> Result<Vec<u8>> {
pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray<u8, U32>) -> Result<Vec<u8>> {
let iv = GenericArray::from_slice(iv);
let mut data = data.clone();
let mut data = data.to_vec();
let decrypted_key_slice = cbc::Decryptor::<aes::Aes256>::new(&key, iv)
.decrypt_padded_mut::<Pkcs7>(&mut data)
.map_err(|_| CryptoError::KeyDecrypt)?;
@@ -54,7 +50,7 @@ pub fn argon2(
let mut hash = [0u8; 32];
argon
.hash_password_into(secret, &salt, &mut hash)
.hash_password_into(secret, salt, &mut hash)
.map_err(|e| KdfParamError::InvalidParams(format!("Argon2 hashing failed: {e}",)))?;
// Argon2 is using some stack memory that is not zeroed. Eventually some function will

View File

@@ -2,4 +2,5 @@ pub use cipher_string::*;
pub use crypto::*;
mod cipher_string;
#[allow(clippy::module_inception)]
mod crypto;

View File

@@ -44,42 +44,40 @@ pub fn path(name: &str) -> std::path::PathBuf {
format!(r"\\.\pipe\{hash_b64}.app.{name}").into()
}
#[cfg(all(target_os = "macos", not(debug_assertions)))]
#[cfg(target_os = "macos")]
{
let mut home = dirs::home_dir().unwrap();
// When running in an unsandboxed environment, path is: /Users/<user>/
// While running sandboxed, it's different: /Users/<user>/Library/Containers/com.bitwarden.desktop/Data
//
// We want to use App Groups in /Users/<user>/Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop,
// so we need to remove all the components after the user.
// Note that we subtract 3 because the root directory is counted as a component (/, Users, <user>).
let num_components = home.components().count();
for _ in 0..num_components - 3 {
home.pop();
let mut home = dirs::home_dir().unwrap();
// Check if the app is sandboxed by looking for the Containers directory
let containers_position = home
.components()
.position(|c| c.as_os_str() == "Containers");
// If the app is sanboxed, we need to use the App Group directory
if let Some(position) = containers_position {
// We want to use App Groups in /Users/<user>/Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop,
// so we need to remove all the components after the user. We can use the previous position to do this.
while home.components().count() > position - 1 {
home.pop();
}
let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop/tmp");
// The tmp directory might not exist, so create it
let _ = std::fs::create_dir_all(&tmp);
return tmp.join(format!("app.{name}"));
}
let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop/tmp");
// The tmp directory might not exist, so create it
let _ = std::fs::create_dir_all(&tmp);
tmp.join(format!("app.{name}"))
}
#[cfg(all(target_os = "macos", debug_assertions))]
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
// When running in debug mode, we use the tmp dir because the app is not sandboxed
let dir = std::env::temp_dir();
dir.join(format!("app.{name}"))
}
#[cfg(target_os = "linux")]
{
// On Linux, we use the user's cache directory.
// On Linux and unsandboxed Mac, we use the user's cache directory.
let home = dirs::cache_dir().unwrap();
let path_dir = home.join("com.bitwarden.desktop");
// The chache directory might not exist, so create it
// The cache directory might not exist, so create it
let _ = std::fs::create_dir_all(&path_dir);
path_dir.join(format!("app.{name}"))
}

View File

@@ -1,16 +1,10 @@
pub mod autofill;
#[cfg(feature = "sys")]
pub mod biometric;
#[cfg(feature = "sys")]
pub mod clipboard;
pub mod crypto;
pub mod error;
pub mod ipc;
#[cfg(feature = "sys")]
pub mod password;
#[cfg(feature = "sys")]
pub mod powermonitor;
#[cfg(feature = "sys")]
pub mod process_isolation;
#[cfg(feature = "sys")]
pub mod ssh_agent;

View File

@@ -4,18 +4,18 @@ use security_framework::passwords::{
};
pub async fn get_password(service: &str, account: &str) -> Result<String> {
let result = String::from_utf8(get_generic_password(&service, &account)?)?;
let result = String::from_utf8(get_generic_password(service, account)?)?;
Ok(result)
}
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let result = set_generic_password(&service, &account, password.as_bytes())?;
Ok(result)
set_generic_password(service, account, password.as_bytes())?;
Ok(())
}
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
let result = delete_generic_password(&service, &account)?;
Ok(result)
delete_generic_password(service, account)?;
Ok(())
}
pub async fn is_available() -> Result<bool> {

View File

@@ -1,3 +1,4 @@
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]

View File

@@ -11,9 +11,10 @@ pub async fn get_password(service: &str, account: &str) -> Result<String> {
async fn get_password_new(service: &str, account: &str) -> Result<String> {
let keyring = oo7::Keyring::new().await?;
let _ = try_prompt(&keyring).await;
let attributes = HashMap::from([("service", service), ("account", account)]);
let results = keyring.search_items(&attributes).await?;
let res = results.get(0);
let res = results.first();
match res {
Some(res) => {
let secret = res.secret().await?;
@@ -29,9 +30,10 @@ async fn get_password_legacy(service: &str, account: &str) -> Result<String> {
let svc = dbus::Service::new().await?;
let collection = svc.default_collection().await?;
let keyring = oo7::Keyring::DBus(collection);
let _ = try_prompt(&keyring).await;
let attributes = HashMap::from([("service", service), ("account", account)]);
let results = keyring.search_items(&attributes).await?;
let res = results.get(0);
let res = results.first();
match res {
Some(res) => {
let secret = res.secret().await?;
@@ -50,6 +52,7 @@ async fn get_password_legacy(service: &str, account: &str) -> Result<String> {
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {
let keyring = oo7::Keyring::new().await?;
let _ = try_prompt(&keyring).await;
let attributes = HashMap::from([("service", service), ("account", account)]);
keyring
.create_item(
@@ -62,13 +65,62 @@ pub async fn set_password(service: &str, account: &str, password: &str) -> Resul
Ok(())
}
/// Remove a credential from the OS keyring. This function will *not* automatically
/// prompt the user to unlock their keyring. If the keyring is locked when this
/// is called, it will fail silently.
pub async fn delete_password(service: &str, account: &str) -> Result<()> {
// We need to silently fail in the event that the user's keyring was
// locked while our application was in-use. Otherwise, when we
// force a de-auth because we can't access keys in secure storage,
// kwallet will notify the user that an application is "misbehaving". This
// seems to happen because we call [delete_password] many times when a forced
// de-auth occurs to clean up old keys.
if is_locked().await? {
println!("skipping deletion of old keys. OS keyring is locked.");
return Ok(());
}
let keyring = oo7::Keyring::new().await?;
let attributes = HashMap::from([("service", service), ("account", account)]);
keyring.delete(&attributes).await?;
Ok(())
}
/// Sends an OS notification prompt for the user to unlock/allow the application
/// to read and write keys.
async fn try_prompt(keyring: &oo7::Keyring) -> bool {
keyring.unlock().await.is_ok()
}
/// Keyrings on Linux cannnot be assumed to be unlocked while the user is
/// logged in to a desktop session. Therefore, before reading or writing
/// keys, you should check if the keyring is unlocked, and call
/// [try_prompt] if ignoring the lock state is not an option.
pub async fn is_locked() -> Result<bool> {
let keyring = oo7::Keyring::new().await?;
// No simple way to check keyring lock state, so we just try to list items
let items = keyring.items().await?;
if let Some(item) = items.first() {
return match item.is_locked().await {
Ok(is_locked) => {
println!("OS keyring is locked = {is_locked}");
Ok(is_locked)
}
Err(_) => {
println!("OS keyring is unlocked");
Ok(false)
}
};
}
// assume it's locked
Ok(true)
}
/// This will return true if a keyring is configured. However, on Linux, it does
/// NOT indicate if the keyring is _unlocked_. Use [is_locked] to check
/// the lock state before reading or writing keys.
pub async fn is_available() -> Result<bool> {
match oo7::Keyring::new().await {
Ok(_) => Ok(true),

View File

@@ -13,7 +13,7 @@ use windows::{
const CRED_FLAGS_NONE: u32 = 0;
pub async fn get_password<'a>(service: &str, account: &str) -> Result<String> {
pub async fn get_password(service: &str, account: &str) -> Result<String> {
let target_name = U16CString::from_str(target_name(service, account))?;
let mut credential: *mut CREDENTIALW = std::ptr::null_mut();
@@ -42,7 +42,7 @@ pub async fn get_password<'a>(service: &str, account: &str) -> Result<String> {
.to_string_lossy()
};
Ok(String::from(password))
Ok(password)
}
pub async fn set_password(service: &str, account: &str, password: &str) -> Result<()> {

View File

@@ -1,3 +1,4 @@
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]

View File

@@ -3,5 +3,5 @@ pub async fn on_lock(_: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn std
}
pub async fn is_lock_monitor_available() -> bool {
return false;
false
}

View File

@@ -1,3 +1,4 @@
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]

View File

@@ -1,45 +0,0 @@
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use ssh_key::{Algorithm, HashAlg, LineEnding};
use super::importer::SshKey;
pub async fn generate_keypair(key_algorithm: String) -> Result<SshKey, anyhow::Error> {
// sourced from cryptographically secure entropy source, with sources for all targets: https://docs.rs/getrandom
// if it cannot be securely sourced, this will panic instead of leading to a weak key
let mut rng: ChaCha8Rng = ChaCha8Rng::from_entropy();
let key = match key_algorithm.as_str() {
"ed25519" => ssh_key::PrivateKey::random(&mut rng, Algorithm::Ed25519),
"rsa2048" | "rsa3072" | "rsa4096" => {
let bits = match key_algorithm.as_str() {
"rsa2048" => 2048,
"rsa3072" => 3072,
"rsa4096" => 4096,
_ => return Err(anyhow::anyhow!("Unsupported RSA key size")),
};
let rsa_keypair = ssh_key::private::RsaKeypair::random(&mut rng, bits)
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
let private_key = ssh_key::PrivateKey::new(
ssh_key::private::KeypairData::from(rsa_keypair),
"".to_string(),
)
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
Ok(private_key)
}
_ => {
return Err(anyhow::anyhow!("Unsupported key algorithm"));
}
}
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
let private_key_openssh = key
.to_openssh(LineEnding::LF)
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
Ok(SshKey {
private_key: private_key_openssh.to_string(),
public_key: key.public_key().to_string(),
key_fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
})
}

View File

@@ -1,424 +0,0 @@
use ed25519;
use pkcs8::{
der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument,
};
use ssh_key::{
private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair},
HashAlg, LineEnding,
};
const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----";
const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----";
const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier =
ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
#[derive(Debug)]
enum KeyType {
Ed25519,
Rsa,
Unknown,
}
pub fn import_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
match encoded_key.lines().next() {
Some(PKCS1_HEADER) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
});
}
Some(PKCS8_UNENCRYPTED_HEADER) => {
return match import_pkcs8_key(encoded_key, None) {
Ok(result) => Ok(result),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
};
}
Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) {
Ok(result) => {
return Ok(result);
}
Err(err) => match err {
SshKeyImportError::PasswordRequired => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
});
}
SshKeyImportError::WrongPassword => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
SshKeyImportError::ParsingError => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
},
},
Some(OPENSSH_HEADER) => {
return import_openssh_key(encoded_key, password);
}
Some(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
None => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
}
}
fn import_pkcs8_key(
encoded_key: String,
password: Option<String>,
) -> Result<SshKeyImportResult, SshKeyImportError> {
let der = match SecretDocument::from_pem(&encoded_key) {
Ok((_, doc)) => doc,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
let decrypted_der = match password.clone() {
Some(password) => {
let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes())
{
Ok(info) => info,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
match encrypted_private_key_info.decrypt(password.as_bytes()) {
Ok(der) => der,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
}
None => der,
};
let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes())
.map_err(|_| SshKeyImportError::ParsingError)?
.algorithm
.oid
{
ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519,
RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa,
_ => KeyType::Unknown,
};
match key_type {
KeyType::Ed25519 => {
let pk: ed25519::KeypairBytes = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
ed25519::pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let pk: Ed25519Keypair =
Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key));
let private_key = ssh_key::private::PrivateKey::from(pk);
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
});
}
KeyType::Rsa => {
let pk: rsa::RsaPrivateKey = match password {
Some(password) => {
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
.map_err(|err| match err {
pkcs8::Error::EncryptedPrivateKey(_) => {
SshKeyImportError::WrongPassword
}
_ => SshKeyImportError::ParsingError,
})?
}
None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
.map_err(|_| SshKeyImportError::ParsingError)?,
};
let rsa_keypair: Result<RsaKeypair, ssh_key::Error> = RsaKeypair::try_from(pk);
match rsa_keypair {
Ok(rsa_keypair) => {
let private_key = ssh_key::private::PrivateKey::from(rsa_keypair);
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key
.to_openssh(LineEnding::LF)
.unwrap()
.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
});
}
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
}
}
_ => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
});
}
}
}
fn import_openssh_key(
encoded_key: String,
password: String,
) -> Result<SshKeyImportResult, anyhow::Error> {
let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key);
let private_key = match private_key {
Ok(k) => k,
Err(err) => {
match err {
ssh_key::Error::AlgorithmUnknown
| ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::UnsupportedKeyType,
ssh_key: None,
});
}
_ => {}
}
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
});
}
};
if private_key.is_encrypted() && password.is_empty() {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::PasswordRequired,
ssh_key: None,
});
}
let private_key = if private_key.is_encrypted() {
match private_key.decrypt(password.as_bytes()) {
Ok(k) => k,
Err(_) => {
return Ok(SshKeyImportResult {
status: SshKeyImportStatus::WrongPassword,
ssh_key: None,
});
}
}
} else {
private_key
};
match private_key.to_openssh(LineEnding::LF) {
Ok(private_key_openssh) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::Success,
ssh_key: Some(SshKey {
private_key: private_key_openssh.to_string(),
public_key: private_key.public_key().to_string(),
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
}),
}),
Err(_) => Ok(SshKeyImportResult {
status: SshKeyImportStatus::ParsingError,
ssh_key: None,
}),
}
}
#[derive(PartialEq, Debug)]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported
UnsupportedKeyType,
}
pub enum SshKeyImportError {
ParsingError,
PasswordRequired,
WrongPassword,
}
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
pub struct SshKey {
pub private_key: String,
pub public_key: String,
pub key_fingerprint: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn import_key_ed25519_openssh_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_unencrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_unencrypted");
let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_openssh_encrypted() {
let private_key = include_str!("./test_keys/rsa_openssh_encrypted");
let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted");
let public_key =
include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_unencrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted");
// for whatever reason pkcs8 + rsa does not include the comment in the public key
let public_key =
include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_rsa_pkcs8_encrypted() {
let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted");
let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", "");
let public_key = public_key.trim();
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::Success);
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
}
#[test]
fn import_key_ed25519_openssh_encrypted_wrong_password() {
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::WrongPassword);
}
#[test]
fn import_non_key_error() {
let result = import_key("not a key".to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_ecdsa_error() {
let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_ed25519_putty() {
let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
// Putty-exported keys should be supported, but are not due to a parser incompatibility.
// Should this test start failing, please change it to expect a correct key, and
// make sure the documentation support for putty-exported keys this is updated.
// https://bitwarden.atlassian.net/browse/PM-14989
#[test]
fn import_key_rsa_openssh_putty() {
let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
}
#[test]
fn import_key_rsa_pkcs8_putty() {
let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted");
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
}
}

View File

@@ -16,9 +16,9 @@ mod platform_ssh_agent;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod peercred_unix_listener_stream;
pub mod generator;
pub mod importer;
pub mod peerinfo;
mod request_parser;
#[derive(Clone)]
pub struct BitwardenDesktopAgent {
keystore: ssh_agent::KeyStore,
@@ -36,19 +36,37 @@ pub struct SshAgentUIRequest {
pub cipher_id: Option<String>,
pub process_name: String,
pub is_list: bool,
pub namespace: Option<String>,
pub is_forwarding: bool,
}
impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
async fn confirm(&self, ssh_key: Key, info: &peerinfo::models::PeerInfo) -> bool {
async fn confirm(&self, ssh_key: Key, data: &[u8], info: &peerinfo::models::PeerInfo) -> bool {
if !self.is_running() {
println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm");
return false;
}
let request_id = self.get_request_id().await;
let request_data = match request_parser::parse_request(data) {
Ok(data) => data,
Err(e) => {
println!("[SSH Agent] Error while parsing request: {}", e);
return false;
}
};
let namespace = match request_data {
request_parser::SshAgentSignRequest::SshSigRequest(ref req) => {
Some(req.namespace.clone())
}
_ => None,
};
println!(
"[SSH Agent] Confirming request from application: {}",
info.process_name()
"[SSH Agent] Confirming request from application: {}, is_forwarding: {}, namespace: {}",
info.process_name(),
info.is_forwarding(),
namespace.clone().unwrap_or_default(),
);
let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe();
@@ -58,6 +76,8 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
cipher_id: Some(ssh_key.cipher_uuid.clone()),
process_name: info.process_name().to_string(),
is_list: false,
namespace,
is_forwarding: info.is_forwarding(),
})
.await
.expect("Should send request to ui");
@@ -82,6 +102,8 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
cipher_id: None,
process_name: info.process_name().to_string(),
is_list: true,
namespace: None,
is_forwarding: info.is_forwarding(),
};
self.show_ui_request_tx
.send(message)
@@ -94,6 +116,17 @@ impl ssh_agent::Agent<peerinfo::models::PeerInfo> for BitwardenDesktopAgent {
}
false
}
async fn set_is_forwarding(
&self,
is_forwarding: bool,
connection_info: &peerinfo::models::PeerInfo,
) {
// is_forwarding can only be added but never removed from a connection
if is_forwarding {
connection_info.set_forwarding(is_forwarding);
}
}
}
impl BitwardenDesktopAgent {
@@ -129,7 +162,7 @@ impl BitwardenDesktopAgent {
.store(true, std::sync::atomic::Ordering::Relaxed);
for (key, name, cipher_id) in new_keys.iter() {
match parse_key_safe(&key) {
match parse_key_safe(key) {
Ok(private_key) => {
let public_key_bytes = private_key
.public_key()
@@ -187,10 +220,8 @@ impl BitwardenDesktopAgent {
return 0;
}
let request_id = self
.request_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
request_id
self.request_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn is_running(&self) -> bool {

View File

@@ -61,7 +61,7 @@ impl NamedPipeServerStream {
}
};
let peer_info = peerinfo::gather::get_peer_info(pid as u32);
let peer_info = peerinfo::gather::get_peer_info(pid);
let peer_info = match peer_info {
Err(err) => {
println!("Failed getting process info for pid {} {}", pid, err);

View File

@@ -31,26 +31,15 @@ impl Stream for PeercredUnixListenerStream {
Ok(peer) => match peer.pid() {
Some(pid) => pid,
None => {
return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::Other,
"Failed to get peer PID",
))));
return Poll::Ready(Some(Ok((stream, PeerInfo::unknown()))));
}
},
Err(err) => {
return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::Other,
format!("Failed to get peer credentials: {}", err),
))));
}
Err(_) => return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))),
};
let peer_info = peerinfo::gather::get_peer_info(pid as u32);
match peer_info {
Ok(info) => Poll::Ready(Some(Ok((stream, info)))),
Err(err) => Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::Other,
format!("Failed to get peer info: {}", err),
)))),
Err(_) => Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))),
}
}
Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))),

View File

@@ -1,3 +1,5 @@
use std::sync::{atomic::AtomicBool, Arc};
/**
* Peerinfo represents the information of a peer process connecting over a socket.
* This can be later extended to include more information (icon, app name) for the corresponding application.
@@ -7,6 +9,7 @@ pub struct PeerInfo {
uid: u32,
pid: u32,
process_name: String,
is_forwarding: Arc<AtomicBool>,
}
impl PeerInfo {
@@ -15,6 +18,16 @@ impl PeerInfo {
uid,
pid,
process_name,
is_forwarding: Arc::new(AtomicBool::new(false)),
}
}
pub fn unknown() -> Self {
Self {
uid: 0,
pid: 0,
process_name: "Unknown application".to_string(),
is_forwarding: Arc::new(AtomicBool::new(false)),
}
}
@@ -29,4 +42,14 @@ impl PeerInfo {
pub fn process_name(&self) -> &str {
&self.process_name
}
pub fn is_forwarding(&self) -> bool {
self.is_forwarding
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set_forwarding(&self, value: bool) {
self.is_forwarding
.store(value, std::sync::atomic::Ordering::Relaxed);
}
}

View File

@@ -0,0 +1,41 @@
use bytes::{Buf, Bytes};
#[derive(Debug)]
pub(crate) struct SshSigRequest {
pub namespace: String,
}
#[derive(Debug)]
pub(crate) struct SignRequest {}
#[derive(Debug)]
pub(crate) enum SshAgentSignRequest {
SshSigRequest(SshSigRequest),
SignRequest(SignRequest),
}
pub(crate) fn parse_request(data: &[u8]) -> Result<SshAgentSignRequest, anyhow::Error> {
let mut data = Bytes::copy_from_slice(data);
let magic_header = "SSHSIG";
let header = data.split_to(magic_header.len());
// sshsig; based on https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
if header == magic_header.as_bytes() {
let _version = data.get_u32();
// read until null byte
let namespace = data
.into_iter()
.take_while(|&x| x != 0)
.collect::<Vec<u8>>();
let namespace =
String::from_utf8(namespace).map_err(|_| anyhow::anyhow!("Invalid namespace"))?;
Ok(SshAgentSignRequest::SshSigRequest(SshSigRequest {
namespace,
}))
} else {
// regular sign request
Ok(SshAgentSignRequest::SignRequest(SignRequest {}))
}
}

View File

@@ -28,7 +28,7 @@ impl BitwardenDesktopAgent {
show_ui_request_tx: auth_request_tx,
get_ui_response_rx: auth_response_rx,
request_id: Arc::new(AtomicU32::new(0)),
needs_unlock: Arc::new(AtomicBool::new(false)),
needs_unlock: Arc::new(AtomicBool::new(true)),
is_running: Arc::new(AtomicBool::new(false)),
};
let cloned_agent_state = agent.clone();
@@ -47,11 +47,21 @@ impl BitwardenDesktopAgent {
return;
}
};
ssh_agent_directory
.join(".bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned()
let is_flatpak = std::env::var("container") == Ok("flatpak".to_string());
if !is_flatpak {
ssh_agent_directory
.join(".bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned()
} else {
ssh_agent_directory
.join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock")
.to_str()
.expect("Path should be valid")
.to_owned()
}
}
};

View File

@@ -1,9 +1,9 @@
[package]
name = "macos_provider"
license = "GPL-3.0"
version = "0.0.0"
edition = "2021"
publish = false
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[[bin]]
name = "uniffi-bindgen"
@@ -16,15 +16,15 @@ bench = false
[dependencies]
desktop_core = { path = "../core" }
futures = "=0.3.31"
log = "0.4.22"
serde = { version = "1.0.205", features = ["derive"] }
serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["sync"] }
tokio-util = "0.7.11"
uniffi = { version = "0.28.0", features = ["cli"] }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true }
uniffi = { version = "=0.28.3", features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]
oslog = "0.2.0"
oslog = "=0.2.0"
[build-dependencies]
uniffi = { version = "0.28.0", features = ["build"] }
uniffi = { version = "=0.28.3", features = ["build"] }

View File

@@ -1,10 +1,10 @@
[package]
edition = "2021"
exclude = ["index.node"]
license = "GPL-3.0"
name = "desktop_napi"
version = "0.0.0"
publish = false
exclude = ["index.node"]
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[lib]
crate-type = ["cdylib"]
@@ -16,18 +16,18 @@ manual_test = []
[dependencies]
base64 = "=0.22.1"
hex = "=0.4.3"
anyhow = "=1.0.94"
anyhow = { workspace = true }
desktop_core = { path = "../core" }
napi = { version = "=2.16.13", features = ["async"] }
napi = { version = "=2.16.15", features = ["async"] }
napi-derive = "=2.16.13"
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.127"
tokio = { version = "=1.41.1" }
tokio-util = "=0.7.12"
tokio-stream = "=0.1.15"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tokio-stream = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-registry = "=0.3.0"
windows-registry = "=0.4.0"
[build-dependencies]
napi-build = "=2.1.3"
napi-build = "=2.1.4"

View File

@@ -51,30 +51,19 @@ export declare namespace sshagent {
publicKey: string
keyFingerprint: string
}
export const enum SshKeyImportStatus {
/** ssh key was parsed correctly and will be returned in the result */
Success = 0,
/** ssh key was parsed correctly but is encrypted and requires a password */
PasswordRequired = 1,
/** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */
WrongPassword = 2,
/** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */
ParsingError = 3,
/** ssh key type is not supported (e.g. ecdsa) */
UnsupportedKeyType = 4
export interface SshUiRequest {
cipherId?: string
isList: boolean
processName: string
isForwarding: boolean
namespace?: string
}
export interface SshKeyImportResult {
status: SshKeyImportStatus
sshKey?: SshKey
}
export function serve(callback: (err: Error | null, arg0: string | undefined | null, arg1: boolean, arg2: string) => any): Promise<SshAgentState>
export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise<SshAgentState>
export function stop(agentState: SshAgentState): void
export function isRunning(agentState: SshAgentState): boolean
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
export function lock(agentState: SshAgentState): void
export function importKey(encodedKey: string, password: string): SshKeyImportResult
export function clearKeys(agentState: SshAgentState): void
export function generateKeypair(keyAlgorithm: string): Promise<SshKey>
export class SshAgentState { }
}
export declare namespace processisolations {

View File

@@ -1,209 +1,132 @@
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { existsSync } = require("fs");
const { join } = require("path");
const { platform, arch } = process
const { platform, arch } = process;
let nativeBinding = null
let localFileExisted = false
let loadError = null
let nativeBinding = null;
let localFileExisted = false;
let loadError = null;
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl')
} catch (e) {
return true
function loadFirstAvailable(localFiles, nodeModule) {
for (const localFile of localFiles) {
if (existsSync(join(__dirname, localFile))) {
return require(`./${localFile}`);
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
require(nodeModule);
}
switch (platform) {
case 'android':
case "android":
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'desktop_napi.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.android-arm64.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'desktop_napi.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.android-arm-eabi.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
case "arm64":
nativeBinding = loadFirstAvailable(
["desktop_napi.android-arm64.node"],
"@bitwarden/desktop-napi-android-arm64",
);
break;
case "arm":
nativeBinding = loadFirstAvailable(
["desktop_napi.android-arm.node"],
"@bitwarden/desktop-napi-android-arm",
);
break;
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
throw new Error(`Unsupported architecture on Android ${arch}`);
}
break
case 'win32':
break;
case "win32":
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.win32-x64-msvc.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.win32-ia32-msvc.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.win32-arm64-msvc.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
case "x64":
nativeBinding = loadFirstAvailable(
["desktop_napi.win32-x64-msvc.node"],
"@bitwarden/desktop-napi-win32-x64-msvc",
);
break;
case "ia32":
nativeBinding = loadFirstAvailable(
["desktop_napi.win32-ia32-msvc.node"],
"@bitwarden/desktop-napi-win32-ia32-msvc",
);
break;
case "arm64":
nativeBinding = loadFirstAvailable(
["desktop_napi.win32-arm64-msvc.node"],
"@bitwarden/desktop-napi-win32-arm64-msvc",
);
break;
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
throw new Error(`Unsupported architecture on Windows: ${arch}`);
}
break
case 'darwin':
break;
case "darwin":
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'desktop_napi.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.darwin-x64.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.darwin-arm64.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
case "x64":
nativeBinding = loadFirstAvailable(
["desktop_napi.darwin-x64.node"],
"@bitwarden/desktop-napi-darwin-x64",
);
break;
case "arm64":
nativeBinding = loadFirstAvailable(
["desktop_napi.darwin-arm64.node"],
"@bitwarden/desktop-napi-darwin-arm64",
);
break;
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
throw new Error(`Unsupported architecture on macOS: ${arch}`);
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'desktop_napi.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.freebsd-x64.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
break;
case "freebsd":
nativeBinding = loadFirstAvailable(
["desktop_napi.freebsd-x64.node"],
"@bitwarden/desktop-napi-freebsd-x64",
);
break;
case "linux":
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.linux-x64-musl.node')
)
case "x64":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-x64-musl.node", "desktop_napi.linux-x64-gnu.node"],
"@bitwarden/desktop-napi-linux-x64-musl",
);
break;
case "arm64":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-arm64-musl.node", "desktop_napi.linux-arm64-gnu.node"],
"@bitwarden/desktop-napi-linux-arm64-musl",
);
break;
case "arm":
nativeBinding = loadFirstAvailable(
["desktop_napi.linux-arm-musl.node", "desktop_napi.linux-arm-gnu.node"],
"@bitwarden/desktop-napi-linux-arm-musl",
);
localFileExisted = existsSync(join(__dirname, "desktop_napi.linux-arm-gnueabihf.node"));
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.linux-x64-musl.node')
nativeBinding = require("./desktop_napi.linux-arm-gnueabihf.node");
} else {
nativeBinding = require('@bitwarden/desktop-napi-linux-x64-musl')
nativeBinding = require("@bitwarden/desktop-napi-linux-arm-gnueabihf");
}
} catch (e) {
loadError = e
loadError = e;
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.linux-arm64-musl.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(
join(__dirname, 'desktop_napi.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./desktop_napi.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('@bitwarden/desktop-napi-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
break
break;
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
throw new Error(`Unsupported architecture on Linux: ${arch}`);
}
break
break;
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`);
}
if (!nativeBinding) {
if (loadError) {
throw loadError
throw loadError;
}
throw new Error(`Failed to load native binding`)
throw new Error(`Failed to load native binding`);
}
module.exports = nativeBinding
module.exports = nativeBinding;

View File

@@ -89,11 +89,9 @@ pub mod biometrics {
account: String,
key_material: Option<KeyMaterial>,
) -> napi::Result<String> {
let result =
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()));
result
Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into()))
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
/// Derives key material from biometric data. Returns a string encoded with a
@@ -184,70 +182,18 @@ pub mod sshagent {
pub key_fingerprint: String,
}
impl From<desktop_core::ssh_agent::importer::SshKey> for SshKey {
fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self {
SshKey {
private_key: key.private_key,
public_key: key.public_key,
key_fingerprint: key.key_fingerprint,
}
}
}
#[napi]
pub enum SshKeyImportStatus {
/// ssh key was parsed correctly and will be returned in the result
Success,
/// ssh key was parsed correctly but is encrypted and requires a password
PasswordRequired,
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
WrongPassword,
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
ParsingError,
/// ssh key type is not supported (e.g. ecdsa)
UnsupportedKeyType,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportStatus> for SshKeyImportStatus {
fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self {
match status {
desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => {
SshKeyImportStatus::Success
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => {
SshKeyImportStatus::PasswordRequired
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => {
SshKeyImportStatus::WrongPassword
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => {
SshKeyImportStatus::ParsingError
}
desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => {
SshKeyImportStatus::UnsupportedKeyType
}
}
}
}
#[napi(object)]
pub struct SshKeyImportResult {
pub status: SshKeyImportStatus,
pub ssh_key: Option<SshKey>,
}
impl From<desktop_core::ssh_agent::importer::SshKeyImportResult> for SshKeyImportResult {
fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self {
SshKeyImportResult {
status: result.status.into(),
ssh_key: result.ssh_key.map(|k| k.into()),
}
}
pub struct SshUIRequest {
pub cipher_id: Option<String>,
pub is_list: bool,
pub process_name: String,
pub is_forwarding: bool,
pub namespace: Option<String>,
}
#[napi]
pub async fn serve(
callback: ThreadsafeFunction<(Option<String>, bool, String), CalleeHandled>,
callback: ThreadsafeFunction<SshUIRequest, CalleeHandled>,
) -> napi::Result<SshAgentState> {
let (auth_request_tx, mut auth_request_rx) =
tokio::sync::mpsc::channel::<desktop_core::ssh_agent::SshAgentUIRequest>(32);
@@ -264,11 +210,13 @@ pub mod sshagent {
let auth_response_tx_arc = cloned_response_tx_arc;
let callback = cloned_callback;
let promise_result: Result<Promise<bool>, napi::Error> = callback
.call_async(Ok((
request.cipher_id,
request.is_list,
request.process_name,
)))
.call_async(Ok(SshUIRequest {
cipher_id: request.cipher_id,
is_list: request.is_list,
process_name: request.process_name,
is_forwarding: request.is_forwarding,
namespace: request.namespace,
}))
.await;
match promise_result {
Ok(promise_result) => match promise_result.await {
@@ -350,13 +298,6 @@ pub mod sshagent {
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub fn import_key(encoded_key: String, password: String) -> napi::Result<SshKeyImportResult> {
let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(result.into())
}
#[napi]
pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> {
let bitwarden_agent_state = &mut agent_state.state;
@@ -364,14 +305,6 @@ pub mod sshagent {
.clear_keys()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn generate_keypair(key_algorithm: String) -> napi::Result<SshKey> {
desktop_core::ssh_agent::generator::generate_keypair(key_algorithm)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|k| k.into())
}
}
#[napi]
@@ -409,8 +342,8 @@ pub mod powermonitors {
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
tokio::spawn(async move {
while let Some(message) = rx.recv().await {
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
while let Some(()) = rx.recv().await {
callback.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
Ok(())
@@ -860,6 +793,6 @@ pub mod crypto {
desktop_core::crypto::argon2(&secret, &salt, iterations, memory, parallelism)
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.to_vec())
.map(|v| Buffer::from(v))
.map(Buffer::from)
}
}

View File

@@ -1,21 +1,21 @@
[package]
edition = "2021"
license = "GPL-3.0"
name = "desktop_objc"
version = "0.0.0"
publish = false
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[features]
default = []
[dependencies]
anyhow = "=1.0.94"
thiserror = "=1.0.69"
tokio = "1.39.1"
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "=0.9.4"
core-foundation = "=0.10.0"
[build-dependencies]
cc = "1.0.104"
glob = "0.3.1"
cc = "=1.2.4"
glob = "=0.3.2"

View File

@@ -1,7 +1,6 @@
use glob::glob;
#[cfg(target_os = "macos")]
fn main() {
use glob::glob;
let mut builder = cc::Build::new();
// Auto compile all .m files in the src/native directory

View File

@@ -68,13 +68,13 @@ mod objc {
use super::*;
extern "C" {
pub fn runCommand(context: *mut c_void, value: *const c_char);
pub fn freeObjCString(value: &ObjCString);
unsafe extern "C" {
pub unsafe fn runCommand(context: *mut c_void, value: *const c_char);
pub unsafe fn freeObjCString(value: &ObjCString);
}
/// This function is called from the ObjC code to return the output of the command
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn commandReturn(context: &mut CommandContext, value: ObjCString) -> bool {
let value: String = match value.try_into() {
Ok(value) => value,
@@ -100,7 +100,7 @@ mod objc {
}
};
return true;
true
}
}
@@ -115,7 +115,7 @@ pub async fn run_command(input: String) -> Result<String> {
unsafe { objc::runCommand(context.as_ptr(), c_input.as_ptr()) };
// Convert output from ObjC code to Rust string
let objc_output = rx.await?.try_into()?;
let objc_output = rx.await?;
// Convert output from ObjC code to Rust string
// let objc_output = output.try_into()?;

View File

@@ -30,21 +30,24 @@ void runSync(void* context, NSDictionary *params) {
[mappedCredentials addObject:credential];
}
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
if (@available(macos 14, *)) {
if ([type isEqualToString:@"fido2"]) {
NSString *cipherId = credential[@"cipherId"];
NSString *rpId = credential[@"rpId"];
NSString *userName = credential[@"userName"];
NSData *credentialId = decodeBase64URL(credential[@"credentialId"]);
NSData *userHandle = decodeBase64URL(credential[@"userHandle"]);
ASPasskeyCredentialIdentity *credential = [[ASPasskeyCredentialIdentity alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity");
id credential = [[passkeyCredentialIdentityClass alloc]
initWithRelyingPartyIdentifier:rpId
userName:userName
credentialID:credentialId
userHandle:userHandle
recordIdentifier:cipherId];
[mappedCredentials addObject:credential];
[mappedCredentials addObject:credential];
}
}
}

View File

@@ -1,19 +1,18 @@
[package]
edition = "2021"
exclude = ["index.node"]
license = "GPL-3.0"
name = "desktop_proxy"
version = "0.0.0"
publish = false
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[dependencies]
anyhow = "=1.0.94"
desktop_core = { path = "../core", default-features = false }
anyhow = { workspace = true }
desktop_core = { path = "../core" }
futures = "=0.3.31"
log = "=0.4.22"
log = { workspace = true }
simplelog = "=0.12.2"
tokio = { version = "=1.41.1", features = ["io-std", "io-util", "macros", "rt"] }
tokio-util = { version = "=0.7.12", features = ["codec"] }
tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] }
tokio-util = { workspace = true, features = ["codec"] }
[target.'cfg(target_os = "macos")'.dependencies]
embed_plist = "=1.2.2"

View File

@@ -5,6 +5,9 @@ use futures::{FutureExt, SinkExt, StreamExt};
use log::*;
use tokio_util::codec::LengthDelimitedCodec;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "macos")]
embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist");
@@ -49,6 +52,9 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
///
#[tokio::main(flavor = "current_thread")]
async fn main() {
#[cfg(target_os = "windows")]
let should_foreground = windows::allow_foreground();
let sock_path = desktop_core::ipc::path("bitwarden");
let log_path = {
@@ -142,6 +148,9 @@ async fn main() {
// Listen to stdin and send messages to ipc processor.
msg = stdin.next() => {
#[cfg(target_os = "windows")]
should_foreground.store(true, std::sync::atomic::Ordering::Relaxed);
match msg {
Some(Ok(msg)) => {
let m = String::from_utf8(msg.to_vec()).unwrap();

View File

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

View File

@@ -0,0 +1,9 @@
[package]
name = "windows-plugin-authenticator"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[target.'cfg(target_os = "windows")'.build-dependencies]
bindgen = "0.71.1"

View File

@@ -0,0 +1,23 @@
# windows-plugin-authenticator
This is an internal crate that's meant to be a safe abstraction layer over the generated Rust bindings for the Windows WebAuthn Plugin Authenticator API's.
You can find more information about the Windows WebAuthn API's [here](https://github.com/microsoft/webauthn).
## Building
To build this crate, set the following environment variables:
- `LIBCLANG_PATH` -> the path to the `bin` directory of your LLVM install ([more info](https://rust-lang.github.io/rust-bindgen/requirements.html?highlight=libclang_path#installing-clang))
### Bash Example
```
export LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```
### PowerShell Example
```
$env:LIBCLANG_PATH = 'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```

View File

@@ -0,0 +1,22 @@
fn main() {
#[cfg(target_os = "windows")]
windows();
}
#[cfg(target_os = "windows")]
fn windows() {
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
let bindings = bindgen::Builder::default()
.header("pluginauthenticator.hpp")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings.");
bindings
.write_to_file(format!(
"{}\\windows_pluginauthenticator_bindings.rs",
out_dir
))
.expect("Couldn't write bindings.");
}

View File

@@ -0,0 +1,231 @@
/*
Bitwarden's pluginauthenticator.hpp
Source: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
This is a C++ header file, so the extension has been manually
changed from `.h` to `.hpp`, so bindgen will automatically
generate the correct C++ bindings.
More Info: https://rust-lang.github.io/rust-bindgen/cpp.html
*/
/* this ALWAYS GENERATED file contains the definitions for the interfaces */
/* File created by MIDL compiler version 8.01.0628 */
/* @@MIDL_FILE_HEADING( ) */
/* verify that the <rpcndr.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCNDR_H_VERSION__
#define __REQUIRED_RPCNDR_H_VERSION__ 501
#endif
/* verify that the <rpcsal.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCSAL_H_VERSION__
#define __REQUIRED_RPCSAL_H_VERSION__ 100
#endif
#include "rpc.h"
#include "rpcndr.h"
#ifndef __RPCNDR_H_VERSION__
#error this stub requires an updated version of <rpcndr.h>
#endif /* __RPCNDR_H_VERSION__ */
#ifndef COM_NO_WINDOWS_H
#include "windows.h"
#include "ole2.h"
#endif /*COM_NO_WINDOWS_H*/
#ifndef __pluginauthenticator_h__
#define __pluginauthenticator_h__
#if defined(_MSC_VER) && (_MSC_VER >= 1020)
#pragma once
#endif
#ifndef DECLSPEC_XFGVIRT
#if defined(_CONTROL_FLOW_GUARD_XFG)
#define DECLSPEC_XFGVIRT(base, func) __declspec(xfg_virtual(base, func))
#else
#define DECLSPEC_XFGVIRT(base, func)
#endif
#endif
/* Forward Declarations */
#ifndef __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
typedef interface EXPERIMENTAL_IPluginAuthenticator EXPERIMENTAL_IPluginAuthenticator;
#endif /* __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ */
/* header files for imported files */
#include "oaidl.h"
#include "webauthn.h"
#ifdef __cplusplus
extern "C"{
#endif
/* interface __MIDL_itf_pluginauthenticator_0000_0000 */
/* [local] */
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
{
HWND hWnd;
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
DWORD cbEncodedRequest;
/* [size_is] */ byte *pbEncodedRequest;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
{
DWORD cbEncodedResponse;
/* [size_is] */ byte *pbEncodedResponse;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
{
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_c_ifspec;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_s_ifspec;
#ifndef __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
/* interface EXPERIMENTAL_IPluginAuthenticator */
/* [unique][version][uuid][object] */
EXTERN_C const IID IID_EXPERIMENTAL_IPluginAuthenticator;
#if defined(__cplusplus) && !defined(CINTERFACE)
MIDL_INTERFACE("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")
EXPERIMENTAL_IPluginAuthenticator : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginMakeCredential(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginGetAssertion(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginCancelOperation(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request) = 0;
};
#else /* C style interface */
typedef struct EXPERIMENTAL_IPluginAuthenticatorVtbl
{
BEGIN_INTERFACE
DECLSPEC_XFGVIRT(IUnknown, QueryInterface)
HRESULT ( STDMETHODCALLTYPE *QueryInterface )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in REFIID riid,
/* [annotation][iid_is][out] */
_COM_Outptr_ void **ppvObject);
DECLSPEC_XFGVIRT(IUnknown, AddRef)
ULONG ( STDMETHODCALLTYPE *AddRef )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(IUnknown, Release)
ULONG ( STDMETHODCALLTYPE *Release )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginMakeCredential)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginMakeCredential )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginGetAssertion)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginGetAssertion )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginCancelOperation)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginCancelOperation )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request);
END_INTERFACE
} EXPERIMENTAL_IPluginAuthenticatorVtbl;
interface EXPERIMENTAL_IPluginAuthenticator
{
CONST_VTBL struct EXPERIMENTAL_IPluginAuthenticatorVtbl *lpVtbl;
};
#ifdef COBJMACROS
#define EXPERIMENTAL_IPluginAuthenticator_QueryInterface(This,riid,ppvObject) \
( (This)->lpVtbl -> QueryInterface(This,riid,ppvObject) )
#define EXPERIMENTAL_IPluginAuthenticator_AddRef(This) \
( (This)->lpVtbl -> AddRef(This) )
#define EXPERIMENTAL_IPluginAuthenticator_Release(This) \
( (This)->lpVtbl -> Release(This) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginMakeCredential(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginMakeCredential(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginGetAssertion(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginGetAssertion(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginCancelOperation(This,request) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginCancelOperation(This,request) )
#endif /* COBJMACROS */
#endif /* C style interface */
#endif /* __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ */
/* Additional Prototypes for ALL interfaces */
unsigned long __RPC_USER HWND_UserSize( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree( __RPC__in unsigned long *, __RPC__in HWND * );
unsigned long __RPC_USER HWND_UserSize64( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal64( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal64(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree64( __RPC__in unsigned long *, __RPC__in HWND * );
/* end of Additional Prototypes */
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,11 @@
#![cfg(target_os = "windows")]
mod pa;
pub fn get_version_number() -> u64 {
unsafe { pa::WebAuthNGetApiVersionNumber() }.into()
}
pub fn add_authenticator() {
unimplemented!();
}

View File

@@ -0,0 +1,15 @@
/*
The 'pa' (plugin authenticator) module will contain the generated
bindgen code.
The attributes below will suppress warnings from the generated code.
*/
#![cfg(target_os = "windows")]
#![allow(clippy::all)]
#![allow(warnings)]
include!(concat!(
env!("OUT_DIR"),
"/windows_pluginauthenticator_bindings.rs"
));

View File

@@ -20,7 +20,7 @@
"**/node_modules/@bitwarden/desktop-napi/index.js",
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
],
"electronVersion": "33.2.1",
"electronVersion": "34.0.0",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",
@@ -133,7 +133,7 @@
"entitlements": "resources/entitlements.mas.plist",
"entitlementsInherit": "resources/entitlements.mas.inherit.plist",
"entitlementsLoginHelper": "resources/entitlements.mas.loginhelper.plist",
"hardenedRuntime": false,
"hardenedRuntime": true,
"extendInfo": {
"LSMinimumSystemVersion": "12",
"ElectronTeamID": "LTZ2PFU5D6"

View File

@@ -1,5 +0,0 @@
{
"rules": {
"no-console": "off"
}
}

View File

@@ -12,15 +12,13 @@
"@bitwarden/common": "file:../../../libs/common",
"@bitwarden/node": "file:../../../libs/node",
"module-alias": "2.2.3",
"node-ipc": "9.2.1",
"ts-node": "10.9.2",
"uuid": "11.0.3",
"uuid": "11.0.5",
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.10.1",
"@types/node-ipc": "9.2.3",
"typescript": "4.7.4"
"@types/node": "22.10.7",
"typescript": "5.4.2"
}
},
"../../../libs/common": {
@@ -31,10 +29,7 @@
"../../../libs/node": {
"name": "@bitwarden/node",
"version": "0.0.0",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/common": "file:../common"
}
"license": "GPL-3.0"
},
"node_modules/@bitwarden/common": {
"resolved": "../../../libs/common",
@@ -106,24 +101,14 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz",
"integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==",
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/node-ipc": {
"version": "9.2.3",
"resolved": "https://registry.npmjs.org/@types/node-ipc/-/node-ipc-9.2.3.tgz",
"integrity": "sha512-/MvSiF71fYf3+zwqkh/zkVkZj1hl1Uobre9EMFy08mqfJNAmpR0vmPgOUdEIDVgifxHj6G1vYMPLSBLLxoDACQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -225,15 +210,6 @@
"node": ">=0.3.1"
}
},
"node_modules/easy-stack": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/easy-stack/-/easy-stack-1.0.1.tgz",
"integrity": "sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -249,15 +225,6 @@
"node": ">=6"
}
},
"node_modules/event-pubsub": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz",
"integrity": "sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==",
"license": "Unlicense",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -276,27 +243,6 @@
"node": ">=8"
}
},
"node_modules/js-message": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",
"integrity": "sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/js-queue": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/js-queue/-/js-queue-2.0.2.tgz",
"integrity": "sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==",
"license": "MIT",
"dependencies": {
"easy-stack": "^1.0.1"
},
"engines": {
"node": ">=1.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -309,20 +255,6 @@
"integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==",
"license": "MIT"
},
"node_modules/node-ipc": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/node-ipc/-/node-ipc-9.2.1.tgz",
"integrity": "sha512-mJzaM6O3xHf9VT8BULvJSbdVbmHUKRNOH7zDDkCrA1/T+CVjq2WVIDfLt0azZRXpgArJtl3rtmEozrbXPZ9GaQ==",
"license": "MIT",
"dependencies": {
"event-pubsub": "4.3.0",
"js-message": "1.0.7",
"js-queue": "2.0.2"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -402,16 +334,16 @@
}
},
"node_modules/typescript": {
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz",
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/undici-types": {
@@ -421,9 +353,9 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

View File

@@ -17,15 +17,13 @@
"@bitwarden/common": "file:../../../libs/common",
"@bitwarden/node": "file:../../../libs/node",
"module-alias": "2.2.3",
"node-ipc": "9.2.1",
"ts-node": "10.9.2",
"uuid": "11.0.3",
"uuid": "11.0.5",
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.10.1",
"@types/node-ipc": "9.2.3",
"typescript": "4.7.4"
"@types/node": "22.10.7",
"typescript": "5.4.2"
},
"_moduleAliases": {
"@bitwarden/common": "dist/libs/common/src",

View File

@@ -7,6 +7,7 @@ import { hideBin } from "yargs/helpers";
import { NativeMessagingVersion } from "@bitwarden/common/enums";
// eslint-disable-next-line no-restricted-imports
import { CredentialCreatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-create-payload";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";

View File

@@ -7,6 +7,7 @@ import { hideBin } from "yargs/helpers";
import { NativeMessagingVersion } from "@bitwarden/common/enums";
// eslint-disable-next-line no-restricted-imports
import { CredentialUpdatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-update-payload";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";

View File

@@ -1,20 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { homedir } from "os";
import * as NodeIPC from "node-ipc";
/* eslint-disable no-console */
import { ChildProcess, spawn } from "child_process";
import * as fs from "fs";
import * as path from "path";
// eslint-disable-next-line no-restricted-imports
import { MessageCommon } from "../../src/models/native-messaging/message-common";
// eslint-disable-next-line no-restricted-imports
import { UnencryptedMessageResponse } from "../../src/models/native-messaging/unencrypted-message-response";
import Deferred from "./deferred";
import { race } from "./race";
NodeIPC.config.id = "native-messaging-test-runner";
NodeIPC.config.maxRetries = 0;
NodeIPC.config.silent = true;
const DESKTOP_APP_PATH = `${homedir}/tmp/app.bitwarden`;
const DEFAULT_MESSAGE_TIMEOUT = 10 * 1000; // 10 seconds
export type MessageHandler = (MessageCommon) => void;
@@ -39,6 +35,10 @@ export default class IPCService {
// A set of deferred promises that are awaiting socket connection
private awaitingConnection = new Set<Deferred<void>>();
// The IPC desktop_proxy process
private process?: ChildProcess;
private processOutputBuffer = Buffer.alloc(0);
constructor(
private socketName: string,
private messageHandler: MessageHandler,
@@ -69,47 +69,47 @@ export default class IPCService {
private _connect() {
this.connectionState = IPCConnectionState.Connecting;
NodeIPC.connectTo(this.socketName, DESKTOP_APP_PATH, () => {
// Process incoming message
this.getSocket().on("message", (message: any) => {
this.processMessage(message);
});
const proxyPath = selectProxyPath();
console.log(`[IPCService] connecting to proxy at ${proxyPath}`);
this.getSocket().on("error", (error: Error) => {
// Only makes sense as long as config.maxRetries stays set to 0. Otherwise this will be
// invoked multiple times each time a connection error happens
console.log("[IPCService] errored");
console.log(
"\x1b[33m Please make sure the desktop app is running locally and 'Allow DuckDuckGo browser integration' setting is enabled \x1b[0m",
);
this.awaitingConnection.forEach((deferred) => {
console.log(`rejecting: ${deferred}`);
deferred.reject(error);
});
this.awaitingConnection.clear();
});
this.process = spawn(proxyPath, process.argv.slice(1), {
cwd: process.cwd(),
stdio: "pipe",
shell: false,
});
this.getSocket().on("connect", () => {
console.log("[IPCService] connected");
this.connectionState = IPCConnectionState.Connected;
this.process.stdout.on("data", (data: Buffer) => {
this.processIpcMessage(data);
});
this.awaitingConnection.forEach((deferred) => {
deferred.resolve(null);
});
this.awaitingConnection.clear();
});
this.process.stderr.on("data", (data: Buffer) => {
console.error(`proxy log: ${data}`);
});
this.getSocket().on("disconnect", () => {
console.log("[IPCService] disconnected");
this.connectionState = IPCConnectionState.Disconnected;
this.process.on("error", (error) => {
// Only makes sense as long as config.maxRetries stays set to 0. Otherwise this will be
// invoked multiple times each time a connection error happens
console.log("[IPCService] errored");
console.log(
"\x1b[33m Please make sure the desktop app is running locally and 'Allow DuckDuckGo browser integration' setting is enabled \x1b[0m",
);
this.awaitingConnection.forEach((deferred) => {
console.log(`rejecting: ${deferred}`);
deferred.reject(error);
});
this.awaitingConnection.clear();
});
this.process.on("exit", () => {
console.log("[IPCService] disconnected");
this.connectionState = IPCConnectionState.Disconnected;
});
}
disconnect() {
console.log("[IPCService] disconnecting...");
if (this.connectionState !== IPCConnectionState.Disconnected) {
NodeIPC.disconnect(this.socketName);
this.process?.kill();
}
}
@@ -130,7 +130,7 @@ export default class IPCService {
this.pendingMessages.set(message.messageId, deferred);
this.getSocket().emit("message", message);
this.sendIpcMessage(message);
try {
// Since we can not guarantee that a response message will ever be sent, we put a timeout
@@ -148,8 +148,56 @@ export default class IPCService {
}
}
private getSocket() {
return NodeIPC.of[this.socketName];
// As we're using the desktop_proxy to communicate with the native messaging directly,
// the messages need to follow Native Messaging Host protocol (uint32 size followed by message).
// https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging#native-messaging-host-protocol
private sendIpcMessage(message: MessageCommon) {
const messageStr = JSON.stringify(message);
const buffer = Buffer.alloc(4 + messageStr.length);
buffer.writeUInt32LE(messageStr.length, 0);
buffer.write(messageStr, 4);
this.process?.stdin.write(buffer);
}
private processIpcMessage(data: Buffer) {
this.processOutputBuffer = Buffer.concat([this.processOutputBuffer, data]);
// We might receive more than one IPC message per data event, so we need to process them all
// We continue as long as we have at least 4 + 1 bytes in the buffer, where the first 4 bytes
// represent the message length and the 5th byte is the message
while (this.processOutputBuffer.length > 4) {
// Read the message length and ensure we have the full message
const msgLength = this.processOutputBuffer.readUInt32LE(0);
if (msgLength + 4 < this.processOutputBuffer.length) {
return;
}
// Parse the message from the buffer
const messageStr = this.processOutputBuffer.subarray(4, msgLength + 4).toString();
const message = JSON.parse(messageStr);
// Store the remaining buffer, which is part of the next message
this.processOutputBuffer = this.processOutputBuffer.subarray(msgLength + 4);
// Process the connect/disconnect messages separately
if (message?.command === "connected") {
console.log("[IPCService] connected");
this.connectionState = IPCConnectionState.Connected;
this.awaitingConnection.forEach((deferred) => {
deferred.resolve(null);
});
this.awaitingConnection.clear();
continue;
} else if (message?.command === "disconnected") {
console.log("[IPCService] disconnected");
this.connectionState = IPCConnectionState.Disconnected;
continue;
}
this.processMessage(message);
}
}
private processMessage(message: any) {
@@ -169,3 +217,41 @@ export default class IPCService {
}
}
}
function selectProxyPath(): string {
const proxyExtension = process.platform === "win32" ? ".exe" : "";
// If the PROXY_PATH environment variable is set, use that
if (process.env.PROXY_PATH) {
if (!fs.existsSync(process.env.PROXY_PATH)) {
throw new Error(`PROXY_PATH is set to ${process.env.PROXY_PATH} but the file does not exist`);
}
return process.env.PROXY_PATH;
}
// Otherwise try the debug build if present
const debugProxyPath = path.join(
__dirname,
"..",
"..",
"..",
"..",
"..",
"..",
"desktop_native",
"target",
"debug",
`desktop_proxy${proxyExtension}`,
);
if (fs.existsSync(debugProxyPath)) {
return debugProxyPath;
}
// On MacOS, try the release build (sandboxed)
const macReleaseProxyPath = `/Applications/Bitwarden.app/Contents/MacOS/desktop_proxy${proxyExtension}`;
if (process.platform === "darwin" && fs.existsSync(macReleaseProxyPath)) {
return macReleaseProxyPath;
}
throw new Error("Could not find the desktop_proxy executable");
}

View File

@@ -1,21 +1,30 @@
/* eslint-disable no-console */
import "module-alias/register";
import { v4 as uuidv4 } from "uuid";
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service";
// eslint-disable-next-line no-restricted-imports
import { DecryptedCommandData } from "../../src/models/native-messaging/decrypted-command-data";
// eslint-disable-next-line no-restricted-imports
import { EncryptedMessage } from "../../src/models/native-messaging/encrypted-message";
// eslint-disable-next-line no-restricted-imports
import { CredentialCreatePayload } from "../../src/models/native-messaging/encrypted-message-payloads/credential-create-payload";
// eslint-disable-next-line no-restricted-imports
import { CredentialUpdatePayload } from "../../src/models/native-messaging/encrypted-message-payloads/credential-update-payload";
// eslint-disable-next-line no-restricted-imports
import { EncryptedMessageResponse } from "../../src/models/native-messaging/encrypted-message-response";
// eslint-disable-next-line no-restricted-imports
import { MessageCommon } from "../../src/models/native-messaging/message-common";
// eslint-disable-next-line no-restricted-imports
import { UnencryptedMessage } from "../../src/models/native-messaging/unencrypted-message";
// eslint-disable-next-line no-restricted-imports
import { UnencryptedMessageResponse } from "../../src/models/native-messaging/unencrypted-message-response";
import IPCService, { IPCOptions } from "./ipc.service";

View File

@@ -5,12 +5,17 @@
"target": "es6",
"module": "CommonJS",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": false,
"declaration": false,
"paths": {
"@src/*": ["src/*"],
"@bitwarden/node/*": ["../../../libs/node/src/*"],
"@bitwarden/common/*": ["../../../libs/common/src/*"]
"@bitwarden/admin-console/*": ["../../../libs/admin-console/src/*"],
"@bitwarden/auth/*": ["../../../libs/auth/src/*"],
"@bitwarden/common/*": ["../../../libs/common/src/*"],
"@bitwarden/key-management": ["../../../libs/key-management/src/"],
"@bitwarden/node/*": ["../../../libs/node/src/*"]
},
"plugins": [
{

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.12.2",
"version": "2025.3.0",
"keywords": [
"bitwarden",
"password",
@@ -19,7 +19,7 @@
"postinstall": "electron-rebuild",
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
"build-native": "cd desktop_native && node build.js",
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
"build:dev": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\"",
"build:preload": "cross-env NODE_ENV=production webpack --config webpack.preload.js",
"build:preload:watch": "cross-env NODE_ENV=production webpack --config webpack.preload.js --watch",
@@ -35,7 +35,7 @@
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && mksquashfs ./dist/tmp-snap/ $SNAP_FILE -noappend -comp lzo -no-fragments && rm -rf ./dist/tmp-snap/",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snapcraft pack ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never",

View File

@@ -1,4 +1,9 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-require-imports */
module.exports = {
plugins: [require("tailwindcss"), require("autoprefixer"), require("postcss-nested")],
plugins: [
require("postcss-import"),
require("postcss-nested"),
require("tailwindcss"),
require("autoprefixer"),
],
};

View File

@@ -8,7 +8,6 @@ command: bitwarden.sh
finish-args:
- --share=ipc
- --share=network
- --socket=wayland
- --socket=x11
- --device=dri
- --env=XDG_CURRENT_DESKTOP=Unity
@@ -33,11 +32,14 @@ modules:
- install bitwarden.sh /app/bin/bitwarden.sh
sources:
- type: dir
only-arches: [x86_64]
path: ../dist/linux-unpacked
- type: dir
only-arches: [aarch64]
path: ../dist/linux-arm64-unpacked
- type: script
dest-filename: bitwarden.sh
commands:
- ulimit -c 0
- export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
- exec zypak-wrapper /app/bin/bitwarden-app --ozone-platform-hint=auto
--enable-features=WaylandWindowDecorations "$@"
- exec zypak-wrapper /app/bin/bitwarden-app "$@"

View File

@@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

View File

@@ -8,5 +8,7 @@
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

View File

@@ -4,10 +4,6 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!--
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>

View File

@@ -6,13 +6,11 @@
<true />
<key>com.apple.security.inherit</key>
<true />
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true />
<key>com.apple.security.cs.disable-library-validation</key>
<key>com.apple.security.cs.allow-jit</key>
<true />
<!--
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
-->
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
-->
</dict>
</plist>

View File

@@ -4,18 +4,12 @@
<dict>
<key>com.apple.application-identifier</key>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string>
<key>com.apple.security.app-sandbox</key>
<true />
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.security.network.client</key>
<true />
<key>com.apple.security.files.user-selected.read-write</key>
<true />
<key>com.apple.security.device.usb</key>
<true />
<!--
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
@@ -34,5 +28,7 @@
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true />
</dict>
</plist>

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
require("dotenv").config();
const child_process = require("child_process");
const path = require("path");
@@ -42,7 +42,7 @@ async function run(context) {
if (process.env.GITHUB_ACTIONS === "true") {
if (is_mas) {
id = is_mas_dev
? "E7C9978F6FBCE0553429185C405E61F5380BE8EB"
? "4B9662CAB74E8E4F4ECBDD9EDEF2543659D95E3C"
: "3rd Party Mac Developer Application: Bitwarden Inc";
} else {
id = "Developer ID Application: 8bit Solutions LLC";

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
require("dotenv").config();
const path = require("path");

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
const child = require("child_process");
const { exit } = require("process");

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-require-imports */
const concurrently = require("concurrently");
const rimraf = require("rimraf");
@@ -25,7 +25,7 @@ concurrently(
},
{
name: "Elec",
command: `npx wait-on ./build/main.js && npx electron --inspect=5858 ${args.join(
command: `npx wait-on ./build/main.js && npx electron --no-sandbox --inspect=5858 ${args.join(
" ",
)} ./build --watch`,
prefixColor: "green",

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
exports.default = async function (configuration) {
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") {

View File

@@ -111,7 +111,7 @@
}}</small>
</div>
</ng-container>
<div class="form-group">
<div class="form-group" *ngIf="(pinEnabled$ | async) || this.form.value.pin">
<div class="checkbox">
<label for="pin">
<input id="pin" type="checkbox" formControlName="pin" />
@@ -163,7 +163,11 @@
formControlName="requirePasswordOnStart"
(change)="updateRequirePasswordOnStart()"
/>
{{ "requirePasswordOnStart" | i18n }}
@if (pinEnabled$ | async) {
{{ "requirePasswordOnStart" | i18n }}
} @else {
{{ "requirePasswordWithoutPinOnStart" | i18n }}
}
</label>
</div>
<small class="help-block form-group-child" *ngIf="isWindows">{{
@@ -334,7 +338,7 @@
</div>
<small id="startToTrayHelp" class="help-block">{{ startToTrayDescText }}</small>
</div>
<div class="form-group">
<div class="form-group" *ngIf="showOpenAtLoginOption">
<div class="checkbox">
<label for="openAtLogin">
<input
@@ -436,6 +440,23 @@
"enableSshAgentDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="!isLinux">
<div class="checkbox">
<label for="allowScreenshots">
<input
id="allowScreenshots"
type="checkbox"
aria-describedby="allowScreenshotsHelp"
formControlName="allowScreenshots"
(change)="savePreventScreenshots()"
/>
{{ "allowScreenshots" | i18n }}
</label>
</div>
<small id="allowScreenshotsHelp" class="help-block">{{
"allowScreenshotsDesc" | i18n
}}</small>
</div>
<div class="form-group" *ngIf="showDuckDuckGoIntegrationOption">
<div class="checkbox">
<label for="enableDuckDuckGoBrowserIntegration">

View File

@@ -0,0 +1,324 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { BiometricStateService, BiometricsStatus, KeyService } from "@bitwarden/key-management";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
import { SettingsComponent } from "./settings.component";
describe("SettingsComponent", () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let originalIpc: any;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const biometricStateService = mock<BiometricStateService>();
const policyService = mock<PolicyService>();
const i18nService = mock<I18nService>();
const autofillSettingsServiceAbstraction = mock<AutofillSettingsServiceAbstraction>();
const desktopSettingsService = mock<DesktopSettingsService>();
const domainSettingsService = mock<DomainSettingsService>();
const desktopAutofillSettingsService = mock<DesktopAutofillSettingsService>();
const themeStateService = mock<ThemeStateService>();
const pinServiceAbstraction = mock<PinServiceAbstraction>();
const desktopBiometricsService = mock<DesktopBiometricsService>();
const platformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => {
originalIpc = (global as any).ipc;
(global as any).ipc = {
auth: {
loginRequest: jest.fn(),
},
platform: {
isDev: false,
isWindowsStore: false,
powermonitor: {
isLockMonitorAvailable: async () => false,
},
},
};
i18nService.supportedTranslationLocales = [];
await TestBed.configureTestingModule({
declarations: [SettingsComponent, I18nPipe],
providers: [
{
provide: AutofillSettingsServiceAbstraction,
useValue: autofillSettingsServiceAbstraction,
},
{ provide: AccountService, useValue: accountService },
{ provide: BiometricStateService, useValue: biometricStateService },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{
provide: DesktopAutofillSettingsService,
useValue: desktopAutofillSettingsService,
},
{ provide: DesktopBiometricsService, useValue: desktopBiometricsService },
{ provide: DesktopSettingsService, useValue: desktopSettingsService },
{ provide: DomainSettingsService, useValue: domainSettingsService },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() },
{
provide: NativeMessagingManifestService,
useValue: mock<NativeMessagingManifestService>(),
},
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService },
{ provide: StateService, useValue: mock<StateService>() },
{ provide: ThemeStateService, useValue: themeStateService },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
of(VaultTimeoutStringType.OnLocked),
);
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(false);
biometricStateService.promptAutomatically$ = of(false);
biometricStateService.requirePasswordOnStart$ = of(false);
autofillSettingsServiceAbstraction.clearClipboardDelay$ = of(null);
desktopSettingsService.minimizeOnCopy$ = of(false);
desktopSettingsService.trayEnabled$ = of(false);
desktopSettingsService.minimizeToTray$ = of(false);
desktopSettingsService.closeToTray$ = of(false);
desktopSettingsService.startToTray$ = of(false);
desktopSettingsService.openAtLogin$ = of(false);
desktopSettingsService.alwaysShowDock$ = of(false);
desktopSettingsService.browserIntegrationEnabled$ = of(false);
desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false);
desktopSettingsService.hardwareAcceleration$ = of(false);
desktopSettingsService.sshAgentEnabled$ = of(false);
desktopSettingsService.preventScreenshots$ = of(false);
domainSettingsService.showFavicons$ = of(false);
desktopAutofillSettingsService.enableDuckDuckGoBrowserIntegration$ = of(false);
themeStateService.selectedTheme$ = of(ThemeType.System);
i18nService.userSetLocale$ = of("en");
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
});
afterEach(() => {
(global as any).ipc = originalIpc;
});
it("pin enabled when RemoveUnlockWithPin policy is not set", async () => {
// @ts-strict-ignore
policyService.get$.mockReturnValue(of(null));
await component.ngOnInit();
await expect(firstValueFrom(component.pinEnabled$)).resolves.toBe(true);
});
it("pin enabled when RemoveUnlockWithPin policy is disabled", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.get$.mockReturnValue(of(policy));
await component.ngOnInit();
await expect(firstValueFrom(component.pinEnabled$)).resolves.toBe(true);
});
it("pin disabled when RemoveUnlockWithPin policy is enabled", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.get$.mockReturnValue(of(policy));
await component.ngOnInit();
await expect(firstValueFrom(component.pinEnabled$)).resolves.toBe(false);
});
it("pin visible when RemoveUnlockWithPin policy is not set", async () => {
// @ts-strict-ignore
policyService.get$.mockReturnValue(of(null));
await component.ngOnInit();
fixture.detectChanges();
const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).not.toBeNull();
expect(pinInputElement.name).toBe("input");
expect(pinInputElement.attributes).toMatchObject({
type: "checkbox",
});
});
it("pin visible when RemoveUnlockWithPin policy is disabled", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.get$.mockReturnValue(of(policy));
await component.ngOnInit();
fixture.detectChanges();
const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).not.toBeNull();
expect(pinInputElement.name).toBe("input");
expect(pinInputElement.attributes).toMatchObject({
type: "checkbox",
});
});
it("pin visible when RemoveUnlockWithPin policy is enabled and pin set", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.get$.mockReturnValue(of(policy));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).not.toBeNull();
expect(pinInputElement.name).toBe("input");
expect(pinInputElement.attributes).toMatchObject({
type: "checkbox",
});
});
it("pin not visible when RemoveUnlockWithPin policy is enabled", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.get$.mockReturnValue(of(policy));
await component.ngOnInit();
fixture.detectChanges();
const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).toBeNull();
});
describe("biometrics enabled", () => {
beforeEach(() => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
});
it("require password or pin on app start message when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.get$.mockReturnValue(of(policy));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).not.toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
});
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password or pin on app start");
});
it("require password on app start message when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.get$.mockReturnValue(of(policy));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).not.toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
});
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password on app start");
});
});
});

View File

@@ -2,11 +2,19 @@
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, Observable, Subject, firstValueFrom } from "rxjs";
import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators";
import { BehaviorSubject, Observable, Subject, firstValueFrom, of } from "rxjs";
import {
concatMap,
debounceTime,
filter,
map,
switchMap,
takeUntil,
tap,
timeout,
} from "rxjs/operators";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -15,27 +23,29 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions, ThemeType } from "@bitwarden/common/platform/enums";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutStringType,
} from "@bitwarden/common/types/vault-timeout.type";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
@@ -43,7 +53,6 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
selector: "app-settings",
templateUrl: "settings.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class SettingsComponent implements OnInit, OnDestroy {
// For use in template
protected readonly VaultTimeoutAction = VaultTimeoutAction;
@@ -54,10 +63,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
themeOptions: any[];
clearClipboardOptions: any[];
supportsBiometric: boolean;
private timerId: any;
showAlwaysShowDock = false;
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
showSshAgentOption = false;
showOpenAtLoginOption = false;
isWindows: boolean;
isLinux: boolean;
@@ -87,6 +98,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
userHasMasterPassword: boolean;
userHasPinSet: boolean;
pinEnabled$: Observable<boolean> = of(true);
form = this.formBuilder.group({
// Security
vaultTimeout: [null as VaultTimeout | null],
@@ -113,8 +126,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
}),
enableHardwareAcceleration: true,
enableSshAgent: false,
allowScreenshots: false,
enableDuckDuckGoBrowserIntegration: false,
theme: [null as ThemeType | null],
theme: [null as Theme | null],
locale: [null as string | null],
});
@@ -138,7 +152,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private userVerificationService: UserVerificationServiceAbstraction,
private desktopSettingsService: DesktopSettingsService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private biometricsService: DesktopBiometricsService,
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
private pinService: PinServiceAbstraction,
private logService: LogService,
@@ -166,6 +180,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.startToTrayText = this.i18nService.t(startToTrayKey);
this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc");
this.showOpenAtLoginOption = !ipc.platform.isWindowsStore;
// DuckDuckGo browser is only for macos initially
this.showDuckDuckGoIntegrationOption = isMac;
@@ -182,10 +198,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.localeOptions = localeOptions;
this.themeOptions = [
{ name: this.i18nService.t("default"), value: ThemeType.System },
{ name: this.i18nService.t("light"), value: ThemeType.Light },
{ name: this.i18nService.t("dark"), value: ThemeType.Dark },
{ name: "Nord", value: ThemeType.Nord },
{ name: this.i18nService.t("default"), value: ThemeTypes.System },
{ name: this.i18nService.t("light"), value: ThemeTypes.Light },
{ name: this.i18nService.t("dark"), value: ThemeTypes.Dark },
];
this.clearClipboardOptions = [
@@ -247,6 +262,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Load initial values
this.userHasPinSet = await this.pinService.isPinSet(activeAccount.id);
this.pinEnabled$ = this.policyService.get$(PolicyType.RemoveUnlockWithPin).pipe(
map((policy) => {
return policy == null || !policy.enabled;
}),
);
const initialValues = {
vaultTimeout: await firstValueFrom(
this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(activeAccount.id),
@@ -282,6 +303,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.desktopSettingsService.hardwareAcceleration$,
),
enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$),
allowScreenshots: !(await firstValueFrom(this.desktopSettingsService.preventScreenshots$)),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: await firstValueFrom(this.i18nService.userSetLocale$),
};
@@ -294,7 +316,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Non-form values
this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop;
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.supportsBiometric = await this.biometricsService.supportsBiometric();
this.previousVaultTimeout = this.form.value.vaultTimeout;
this.refreshTimeoutSettings$
@@ -357,6 +378,23 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.enableBrowserIntegrationFingerprint.disable();
}
});
this.supportsBiometric = this.shouldAllowBiometricSetup(
await this.biometricsService.getBiometricsStatus(),
);
this.timerId = setInterval(async () => {
this.supportsBiometric = this.shouldAllowBiometricSetup(
await this.biometricsService.getBiometricsStatus(),
);
}, 1000);
}
private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean {
return [
BiometricsStatus.Available,
BiometricsStatus.AutoSetupNeeded,
BiometricsStatus.ManualSetupNeeded,
].includes(biometricStatus);
}
async saveVaultTimeout(newValue: VaultTimeout) {
@@ -473,23 +511,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
const needsSetup = await this.biometricsService.biometricsNeedsSetup();
const supportsBiometricAutoSetup = await this.biometricsService.biometricsSupportsAutoSetup();
const status = await this.biometricsService.getBiometricsStatus();
if (needsSetup) {
if (supportsBiometricAutoSetup) {
await this.biometricsService.biometricsSetup();
} else {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
if (status === BiometricsStatus.AutoSetupNeeded) {
await this.biometricsService.setupBiometrics();
} else if (status === BiometricsStatus.ManualSetupNeeded) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
}
await this.biometricStateService.setBiometricUnlockEnabled(true);
@@ -510,8 +545,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
await this.keyService.refreshAdditionalKeys();
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
// Validate the key is stored in case biometrics fail.
const biometricSet = await this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric);
const biometricSet =
(await this.biometricsService.getBiometricsStatusForUser(activeUserId)) ===
BiometricsStatus.Available;
this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
if (!biometricSet) {
await this.biometricStateService.setBiometricUnlockEnabled(false);
@@ -637,7 +677,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
const skipSupportedPlatformCheck =
ipc.platform.allowBrowserintegrationOverride || ipc.platform.isDev;
if (skipSupportedPlatformCheck) {
if (!skipSupportedPlatformCheck) {
if (
ipc.platform.deviceType === DeviceType.MacOsDesktop &&
!this.platformUtilsService.isMacAppStore()
@@ -746,6 +786,33 @@ export class SettingsComponent implements OnInit, OnDestroy {
await this.desktopSettingsService.setSshAgentEnabled(this.form.value.enableSshAgent);
}
async savePreventScreenshots() {
await this.desktopSettingsService.setPreventScreenshots(!this.form.value.allowScreenshots);
if (!this.form.value.allowScreenshots) {
const dialogRef = this.dialogService.openSimpleDialogRef({
title: { key: "confirmWindowStillVisibleTitle" },
content: { key: "confirmWindowStillVisibleContent" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "info",
});
let enabled = true;
try {
enabled = await firstValueFrom(dialogRef.closed.pipe(timeout(10000)));
} catch {
enabled = false;
} finally {
dialogRef.close();
}
if (!enabled) {
await this.desktopSettingsService.setPreventScreenshots(false);
this.form.controls.allowScreenshots.setValue(true, { emitEvent: false });
}
}
}
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
let vaultTimeoutOptions: VaultTimeoutOption[] = [
{ name: this.i18nService.t("oneMinute"), value: 1 },
@@ -776,6 +843,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
clearInterval(this.timerId);
}
get biometricText() {

View File

@@ -1,21 +1,20 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import {
DesktopDefaultOverlayPosition,
EnvironmentSelectorComponent,
} from "@bitwarden/angular/auth/components/environment-selector.component";
import { TwoFactorTimeoutComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-expired.component";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
authGuard,
lockGuard,
activeAuthGuard,
redirectGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
import {
AnonLayoutWrapperComponent,
@@ -23,7 +22,6 @@ import {
LoginComponent,
LoginSecondaryContentComponent,
LockIcon,
LockV2Component,
LoginViaAuthRequestComponent,
PasswordHintComponent,
RegistrationFinishComponent,
@@ -39,28 +37,23 @@ import {
DevicesIcon,
SsoComponent,
TwoFactorTimeoutIcon,
TwoFactorAuthComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
DeviceVerificationIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockComponent } from "@bitwarden/key-management-ui";
import {
NewDeviceVerificationNoticePageOneComponent,
NewDeviceVerificationNoticePageTwoComponent,
VaultIcons,
} from "@bitwarden/vault";
import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component";
import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component";
import { LoginComponentV1 } from "../auth/login/login-v1.component";
import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component";
import { RegisterComponent } from "../auth/register.component";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component";
import { TwoFactorComponent } from "../auth/two-factor.component";
import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
@@ -70,6 +63,7 @@ import { SendComponent } from "./tools/send/send.component";
/**
* Data properties acceptable for use in route objects in the desktop
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface RouteDataProperties {
// For any new route data properties, add them here.
// then assert that the data object satisfies this interface in the route object.
@@ -82,37 +76,35 @@ const routes: Routes = [
children: [], // Children lets us have an empty component.
canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })],
},
{
path: "lock",
component: LockComponent,
canActivate: [lockGuard()],
canMatch: [extensionRefreshRedirect("/lockV2")],
},
...twofactorRefactorSwap(
TwoFactorComponent,
...unauthUiRefreshSwap(
TwoFactorComponentV1,
AnonLayoutWrapperComponent,
{
path: "2fa",
},
{
path: "2fa",
component: AnonLayoutWrapperComponent,
canActivate: [unauthGuardFn(), TwoFactorAuthGuard],
children: [
{
path: "",
component: TwoFactorAuthComponent,
canActivate: [unauthGuardFn()],
},
],
data: {
pageTitle: {
key: "verifyYourIdentity",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
),
{
path: "2fa-timeout",
path: "authentication-timeout",
component: AnonLayoutWrapperComponent,
children: [
{
path: "",
component: TwoFactorTimeoutComponent,
component: AuthenticationTimeoutComponent,
},
],
data: {
@@ -122,7 +114,21 @@ const routes: Routes = [
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{ path: "register", component: RegisterComponent },
{
path: "device-verification",
component: AnonLayoutWrapperComponent,
canActivate: [unauthGuardFn(), activeAuthGuard()],
children: [{ path: "", component: NewDeviceVerificationComponent }],
data: {
pageIcon: DeviceVerificationIcon,
pageTitle: {
key: "verifyYourIdentity",
},
pageSubtitle: {
key: "weDontRecognizeThisDevice",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "new-device-notice",
component: AnonLayoutWrapperComponent,
@@ -157,33 +163,6 @@ const routes: Routes = [
},
{ path: "accessibility-cookie", component: AccessibilityCookieComponent },
{ path: "set-password", component: SetPasswordComponent },
...unauthUiRefreshSwap(
SsoComponentV1,
AnonLayoutWrapperComponent,
{
path: "sso",
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
{
path: "send",
component: SendComponent,
@@ -199,139 +178,10 @@ const routes: Routes = [
component: RemovePasswordComponent,
canActivate: [authGuard],
},
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-with-device",
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "loginInitiated",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
),
...unauthUiRefreshSwap(
LoginViaAuthRequestComponentV1,
AnonLayoutWrapperComponent,
{
path: "admin-approval-requested",
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
),
...unauthUiRefreshSwap(
HintComponent,
AnonLayoutWrapperComponent,
{
path: "hint",
canActivate: [unauthGuardFn()],
},
{
path: "",
children: [
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginComponentV1,
AnonLayoutWrapperComponent,
{
path: "login",
component: LoginComponentV1,
canActivate: [maxAccountsGuardFn()],
},
{
path: "",
children: [
{
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
],
},
),
...unauthUiRefreshSwap(
LoginDecryptionOptionsComponentV1,
AnonLayoutWrapperComponent,
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
),
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
@@ -342,7 +192,7 @@ const routes: Routes = [
children: [
{
path: "signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
canActivate: [unauthGuardFn()],
data: {
pageIcon: RegistrationUserAddIcon,
pageTitle: {
@@ -366,7 +216,7 @@ const routes: Routes = [
},
{
path: "finish-signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
canActivate: [unauthGuardFn()],
data: {
pageIcon: RegistrationLockAltIcon,
} satisfies AnonLayoutWrapperData,
@@ -378,8 +228,112 @@ const routes: Routes = [
],
},
{
path: "lockV2",
canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()],
path: "login",
canActivate: [maxAccountsGuardFn()],
data: {
pageTitle: {
key: "logInToBitwarden",
},
pageIcon: VaultIcon,
},
children: [
{ path: "", component: LoginComponent },
{ path: "", component: LoginSecondaryContentComponent, outlet: "secondary" },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
data: {
overlayPosition: DesktopDefaultOverlayPosition,
},
},
],
},
{
path: "login-initiated",
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
},
{
path: "sso",
data: {
pageIcon: VaultIcon,
pageTitle: {
key: "enterpriseSingleSignOn",
},
pageSubtitle: {
key: "singleSignOnEnterOrgIdentifierText",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: SsoComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "login-with-device",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "logInRequestSent",
},
pageSubtitle: {
key: "aNotificationWasSentToYourDevice",
},
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "admin-approval-requested",
data: {
pageIcon: DevicesIcon,
pageTitle: {
key: "adminApprovalRequested",
},
pageSubtitle: {
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "hint",
canActivate: [unauthGuardFn()],
data: {
pageTitle: {
key: "requestPasswordHint",
},
pageSubtitle: {
key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou",
},
pageIcon: UserLockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{ path: "", component: PasswordHintComponent },
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
},
{
path: "lock",
canActivate: [lockGuard()],
data: {
pageIcon: LockIcon,
pageTitle: {
@@ -390,13 +344,12 @@ const routes: Routes = [
children: [
{
path: "",
component: LockV2Component,
component: LockComponent,
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: {

View File

@@ -12,28 +12,16 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import {
catchError,
filter,
firstValueFrom,
map,
of,
Subject,
takeUntil,
timeout,
withLatestFrom,
} from "rxjs";
import { filter, firstValueFrom, map, Subject, takeUntil, timeout, withLatestFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
import { LogoutReason } from "@bitwarden/auth/common";
import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -43,43 +31,45 @@ import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstrac
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutService,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { CredentialGeneratorHistoryDialogComponent } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
import { DeleteAccountComponent } from "../auth/delete-account.component";
import { PremiumComponent } from "../billing/app/accounts/premium.component";
import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater";
import { flagEnabled } from "../platform/flags";
import { PremiumComponent } from "../vault/app/accounts/premium.component";
import { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.component";
import { SettingsComponent } from "./accounts/settings.component";
import { ExportDesktopComponent } from "./tools/export/export-desktop.component";
import { CredentialGeneratorComponent } from "./tools/generator/credential-generator.component";
import { GeneratorComponent } from "./tools/generator.component";
import { ImportDesktopComponent } from "./tools/import/import-desktop.component";
import { PasswordGeneratorHistoryComponent } from "./tools/password-generator-history.component";
const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes
@@ -104,6 +94,8 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
</div>
<router-outlet *ngIf="!loading"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
`,
})
export class AppComponent implements OnInit, OnDestroy {
@@ -138,7 +130,6 @@ export class AppComponent implements OnInit, OnDestroy {
private broadcasterService: BroadcasterService,
private folderService: InternalFolderService,
private syncService: SyncService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private cipherService: CipherService,
private authService: AuthService,
private router: Router,
@@ -167,27 +158,10 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private accountService: AccountService,
private sdkService: SdkService,
private organizationService: OrganizationService,
private deviceTrustToastService: DeviceTrustToastService,
) {
if (flagEnabled("sdk")) {
// Warn if the SDK for some reason can't be initialized
this.sdkService.supported$
.pipe(
takeUntilDestroyed(),
catchError(() => {
return of(false);
}),
)
.subscribe((supported) => {
if (!supported) {
this.logService.debug("SDK is not supported");
this.sdkService.failedToInitialize("desktop").catch((e) => this.logService.error(e));
} else {
this.logService.debug("SDK is supported");
}
});
}
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
@@ -224,17 +198,11 @@ export class AppComponent implements OnInit, OnDestroy {
this.recordActivity();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.updateConnection();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateAppMenu();
this.processReloadService.cancelProcessReload();
break;
case "loggedOut":
this.modalService.closeAll();
if (message.userId == null || message.userId === this.activeUserId) {
await this.notificationsService.updateConnection();
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateAppMenu();
@@ -278,9 +246,6 @@ export class AppComponent implements OnInit, OnDestroy {
) {
await this.router.navigate(["lock"]);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.updateConnection();
await this.updateAppMenu();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
@@ -342,7 +307,7 @@ export class AppComponent implements OnInit, OnDestroy {
const queryParams = {
code: message.code,
state: message.state,
redirectUri: message.redirectUri ?? "bitwarden://sso-callback",
redirectUri: message.redirectUri ?? DESKTOP_SSO_CALLBACK,
};
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -535,41 +500,13 @@ export class AppComponent implements OnInit, OnDestroy {
}
async openGenerator() {
const isGeneratorSwapEnabled = await this.configService.getFeatureFlag(
FeatureFlag.GeneratorToolsModernization,
);
if (isGeneratorSwapEnabled) {
await this.dialogService.open(CredentialGeneratorComponent);
return;
}
this.modalService.closeAll();
[this.modal] = await this.modalService.openViewRef(
GeneratorComponent,
this.generatorModalRef,
(comp) => (comp.comingFromAddEdit = false),
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.modal.onClosed.subscribe(() => {
this.modal = null;
});
await this.dialogService.open(CredentialGeneratorComponent);
return;
}
async openGeneratorHistory() {
const isGeneratorSwapEnabled = await this.configService.getFeatureFlag(
FeatureFlag.GeneratorToolsModernization,
);
if (isGeneratorSwapEnabled) {
await this.dialogService.open(CredentialGeneratorHistoryDialogComponent);
return;
}
await this.openModal<PasswordGeneratorHistoryComponent>(
PasswordGeneratorHistoryComponent,
this.passwordHistoryRef,
);
await this.dialogService.open(CredentialGeneratorHistoryDialogComponent);
return;
}
private async updateAppMenu() {
@@ -787,12 +724,8 @@ export class AppComponent implements OnInit, OnDestroy {
private idleStateChanged() {
if (this.isIdle) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.disconnectFromInactivity();
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.reconnectFromActivity();
}
}
@@ -883,7 +816,7 @@ export class AppComponent implements OnInit, OnDestroy {
if (urlString.indexOf("bitwarden://import-callback-lp") === 0) {
message = "importCallbackLastPass";
} else if (urlString.indexOf("bitwarden://sso-callback") === 0) {
} else if (urlString.indexOf(DESKTOP_SSO_CALLBACK) === 0) {
message = "ssoCallback";
}
@@ -891,9 +824,10 @@ export class AppComponent implements OnInit, OnDestroy {
}
private async deleteAccount() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.AccountDeprovisioning).pipe(
withLatestFrom(this.organizationService.organizations$),
withLatestFrom(this.organizationService.organizations$(userId)),
map(async ([accountDeprovisioningEnabled, organization]) => {
if (
accountDeprovisioningEnabled &&

View File

@@ -7,23 +7,22 @@ import { NgModule } from "@angular/core";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { DialogModule, CalloutModule } from "@bitwarden/components";
import { CalloutModule, DialogModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { DeleteAccountComponent } from "../auth/delete-account.component";
import { EnvironmentComponent } from "../auth/environment.component";
import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component";
import { LoginModule } from "../auth/login/login.module";
import { RegisterComponent } from "../auth/register.component";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
import { TwoFactorComponent } from "../auth/two-factor.component";
import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component";
import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { SshAgentService } from "../platform/services/ssh-agent.service";
import { PremiumComponent } from "../vault/app/accounts/premium.component";
import { SshAgentService } from "../autofill/services/ssh-agent.service";
import { PremiumComponent } from "../billing/app/accounts/premium.component";
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/app/vault/attachments.component";
@@ -47,8 +46,6 @@ import { HeaderComponent } from "./layout/header.component";
import { NavComponent } from "./layout/nav.component";
import { SearchComponent } from "./layout/search/search.component";
import { SharedModule } from "./shared/shared.module";
import { GeneratorComponent } from "./tools/generator.component";
import { PasswordGeneratorHistoryComponent } from "./tools/password-generator-history.component";
import { AddEditComponent as SendAddEditComponent } from "./tools/send/add-edit.component";
import { SendComponent } from "./tools/send/send.component";
@@ -62,6 +59,7 @@ import { SendComponent } from "./tools/send/send.component";
CalloutModule,
DeleteAccountComponent,
UserVerificationComponent,
DecryptionFailureDialogComponent,
],
declarations: [
AccessibilityCookieComponent,
@@ -78,13 +76,9 @@ import { SendComponent } from "./tools/send/send.component";
FolderAddEditComponent,
HeaderComponent,
HintComponent,
LockComponent,
NavComponent,
GeneratorComponent,
PasswordGeneratorHistoryComponent,
PasswordHistoryComponent,
PremiumComponent,
RegisterComponent,
RemovePasswordComponent,
SearchComponent,
SendAddEditComponent,
@@ -92,9 +86,9 @@ import { SendComponent } from "./tools/send/send.component";
SetPasswordComponent,
SettingsComponent,
ShareComponent,
TwoFactorComponentV1,
SsoComponentV1,
TwoFactorComponent,
TwoFactorOptionsComponent,
TwoFactorOptionsComponentV1,
UpdateTempPasswordComponent,
VaultComponent,
VaultTimeoutInputComponent,

View File

@@ -76,12 +76,10 @@
></app-avatar>
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email" aria-hidden="true">{{ account.value.email }}</span>
<span class="server" aria-hidden="true">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status" aria-hidden="true"
><span class="sr-only">&nbsp;(</span
<span class="email">{{ account.value.email }}</span>
<span class="server"> <span class="sr-only"> / </span>{{ account.value.server }} </span>
<span class="status">
<span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
| i18n

View File

@@ -17,6 +17,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
type ActiveAccount = {
id: string;
name: string;
@@ -90,6 +92,7 @@ export class AccountSwitcherComponent implements OnInit {
private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction,
private accountService: AccountService,
private biometricsService: DesktopBiometricsService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
@@ -107,7 +110,9 @@ export class AccountSwitcherComponent implements OnInit {
name: active.name,
email: active.email,
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.getHostname(),
server: (
await firstValueFrom(this.environmentService.getEnvironment$(active.id))
)?.getHostname(),
};
}),
);
@@ -181,6 +186,7 @@ export class AccountSwitcherComponent implements OnInit {
async switch(userId: string) {
this.close();
await this.biometricsService.setShouldAutopromptNow(true);
this.disabled = true;
const accountSwitchFinishedPromise = firstValueFrom(
@@ -217,7 +223,9 @@ export class AccountSwitcherComponent implements OnInit {
email: baseAccounts[userId].email,
authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
server: (await this.environmentService.getEnvironment(userId))?.getHostname(),
server: (
await firstValueFrom(this.environmentService.getEnvironment$(userId as UserId))
)?.getHostname(),
};
}

View File

@@ -1,7 +1,11 @@
import "core-js/proposals/explicit-resource-management";
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("../scss/styles.scss");
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("../scss/tailwind.css");
import { AppModule } from "./app.module";
@@ -10,9 +14,7 @@ if (!ipc.platform.isDev) {
enableProdMode();
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });
void platformBrowserDynamic().bootstrapModule(AppModule);
// Disable drag and drop to prevent malicious links from executing in the context of the app
document.addEventListener("dragover", (event) => event.preventDefault());

View File

@@ -1,25 +0,0 @@
import { map } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import {
THEME_SELECTION,
ThemeStateService,
} from "@bitwarden/common/platform/theming/theme-state.service";
export class DesktopThemeStateService implements ThemeStateService {
private readonly selectedThemeState = this.globalStateProvider.get(THEME_SELECTION);
selectedTheme$ = this.selectedThemeState.state$.pipe(map((theme) => theme ?? this.defaultTheme));
constructor(
private globalStateProvider: GlobalStateProvider,
private defaultTheme: ThemeType = ThemeType.System,
) {}
async setSelectedTheme(theme: ThemeType): Promise<void> {
await this.selectedThemeState.update(() => theme, {
shouldUpdate: (currentTheme) => currentTheme !== theme,
});
}
}

View File

@@ -5,24 +5,25 @@ import { firstValueFrom } from "rxjs";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { SshAgentService } from "../../autofill/services/ssh-agent.service";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { SshAgentService } from "../../platform/services/ssh-agent.service";
import { VersionService } from "../../platform/services/version.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@@ -31,11 +32,11 @@ export class InitService {
constructor(
@Inject(WINDOW) private win: Window,
private syncService: SyncServiceAbstraction,
private vaultTimeoutService: VaultTimeoutService,
private vaultTimeoutService: DefaultVaultTimeoutService,
private i18nService: I18nServiceAbstraction,
private eventUploadService: EventUploadServiceAbstraction,
private twoFactorService: TwoFactorServiceAbstraction,
private notificationsService: NotificationsServiceAbstraction,
private notificationsService: NotificationsService,
private platformUtilsService: PlatformUtilsServiceAbstraction,
private stateService: StateServiceAbstraction,
private keyService: KeyServiceAbstraction,
@@ -47,11 +48,13 @@ export class InitService {
private versionService: VersionService,
private sshAgentService: SshAgentService,
private autofillService: DesktopAutofillService,
private sdkLoadService: SdkLoadService,
@Inject(DOCUMENT) private document: Document,
) {}
init() {
return async () => {
await this.sdkLoadService.loadAndInit();
await this.sshAgentService.init();
this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
@@ -75,7 +78,7 @@ export class InitService {
await (this.i18nService as I18nRendererService).init();
(this.eventUploadService as EventUploadService).init(true);
this.twoFactorService.init();
setTimeout(() => this.notificationsService.init(), 3000);
this.notificationsService.startListening();
const htmlEl = this.win.document.documentElement;
htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString());
this.themingService.applyThemeChangesTo(this.document);

View File

@@ -25,18 +25,18 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
import {
LoginComponentService,
SetPasswordJitService,
LockComponentService,
SsoComponentService,
DefaultSsoComponentService,
TwoFactorAuthDuoComponentService,
} from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
PinServiceAbstraction,
SsoUrlService,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
@@ -52,10 +52,14 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
@@ -69,6 +73,7 @@ import {
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service";
@@ -79,14 +84,14 @@ import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { DefaultSdkLoadService } from "@bitwarden/common/platform/services/sdk/default-sdk-load.service";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop-sdk-load.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@@ -97,13 +102,18 @@ import {
BiometricStateService,
BiometricsService,
} from "@bitwarden/key-management";
import { LockComponentService } from "@bitwarden/key-management-ui";
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
import { flagEnabled } from "../../platform/flags";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ElectronKeyService } from "../../platform/services/electron-key.service";
@@ -119,7 +129,6 @@ import { I18nRendererService } from "../../platform/services/i18n.renderer.servi
import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging";
import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme";
import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service";
import { DesktopLockComponentService } from "../../services/desktop-lock-component.service";
import { DuckDuckGoMessageHandlerService } from "../../services/duckduckgo-message-handler.service";
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@@ -127,7 +136,6 @@ import { SearchBarService } from "../layout/search/search-bar.service";
import { DesktopFileDownloadService } from "./desktop-file-download.service";
import { DesktopSetPasswordJitService } from "./desktop-set-password-jit.service";
import { DesktopThemeStateService } from "./desktop-theme.service";
import { InitService } from "./init.service";
import { NativeMessagingManifestService } from "./native-messaging-manifest.service";
import { RendererCryptoFunctionService } from "./renderer-crypto-function.service";
@@ -143,7 +151,12 @@ const safeProviders: SafeProvider[] = [
safeProvider(InitService),
safeProvider({
provide: BiometricsService,
useClass: ElectronBiometricsService,
useClass: RendererBiometricsService,
deps: [],
}),
safeProvider({
provide: DesktopBiometricsService,
useClass: RendererBiometricsService,
deps: [],
}),
safeProvider(NativeMessagingService),
@@ -242,6 +255,7 @@ const safeProviders: SafeProvider[] = [
VaultTimeoutSettingsService,
BiometricStateService,
AccountServiceAbstraction,
LogService,
],
}),
safeProvider({
@@ -254,11 +268,6 @@ const safeProviders: SafeProvider[] = [
useFactory: () => fromIpcSystemTheme(),
deps: [],
}),
safeProvider({
provide: ThemeStateService,
useClass: DesktopThemeStateService,
deps: [GlobalStateProvider],
}),
safeProvider({
provide: EncryptedMessageHandlerService,
deps: [
@@ -303,6 +312,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
BiometricStateService,
KdfConfigService,
DesktopBiometricsService,
],
}),
safeProvider({
@@ -380,6 +390,11 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: SsoUrlService,
useClass: SsoUrlService,
deps: [],
}),
safeProvider({
provide: LoginComponentService,
useClass: DesktopLoginComponentService,
@@ -391,6 +406,17 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction,
I18nServiceAbstraction,
ToastService,
SsoUrlService,
],
}),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: DesktopTwoFactorAuthDuoComponentService,
deps: [
MessageListener,
EnvironmentService,
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
],
}),
safeProvider({
@@ -398,6 +424,11 @@ const safeProviders: SafeProvider[] = [
useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory,
deps: [],
}),
safeProvider({
provide: SdkLoadService,
useClass: flagEnabled("sdk") ? DefaultSdkLoadService : NoopSdkLoadService,
deps: [],
}),
safeProvider({
provide: LoginEmailService,
useClass: LoginEmailService,
@@ -413,6 +444,11 @@ const safeProviders: SafeProvider[] = [
useClass: DesktopLoginApprovalComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: SshImportPromptService,
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsServiceAbstraction, I18nServiceAbstraction],
}),
];
@NgModule({

View File

@@ -1,636 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="generatorTitle">
<div class="modal-dialog modal-md" role="document">
<div class="modal-content">
<div class="modal-body">
<h1 class="modal-title" id="generatorTitle">
{{ "generator" | i18n }}
</h1>
<bit-callout
type="info"
*ngIf="enforcedPasswordPolicyOptions?.inEffect() && type === 'password'"
>
{{ "passwordGeneratorPolicyInEffect" | i18n }}
</bit-callout>
<div class="generated-block" *ngIf="type === 'password'">
<div
class="generated-wrapper"
[innerHTML]="password | colorPassword"
[appCopyText]="password"
></div>
<div class="action-buttons">
<button
type="button"
class="icon-btn primary"
appStopClick
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy()"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
<button
type="button"
class="icon-btn primary"
appStopClick
appA11yTitle="{{ 'regeneratePassword' | i18n }}"
(click)="regenerate()"
>
<i class="bwi bwi-lg bwi-generate" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="generated-block" *ngIf="type === 'username'">
<div
class="generated-wrapper"
[innerHTML]="username | colorPassword"
[appCopyText]="username"
></div>
<div class="action-buttons" #form [appApiAction]="usernameGeneratingPromise">
<button
type="button"
class="icon-btn primary"
appStopClick
appA11yTitle="{{ 'copyUsername' | i18n }}"
(click)="copy()"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
<button
type="button"
class="icon-btn primary"
appStopClick
appA11yTitle="{{ 'regenerateUsername' | i18n }}"
(click)="$any(form).loading ? false : regenerate()"
[attr.aria-disabled]="$any(form).loading ? 'true' : null"
>
<i
class="bwi bwi-lg bwi-generate"
[ngClass]="$any(form).loading ? 'bwi-spin' : ''"
aria-hidden="true"
></i>
</button>
</div>
</div>
<div class="box" *ngIf="!comingFromAddEdit">
<div class="box-content condensed">
<div
class="box-content-row box-content-row-radio"
role="radiogroup"
aria-labelledby="typeHeading"
>
<label id="typeHeading" class="radio-header">{{
"whatWouldYouLikeToGenerate" | i18n
}}</label>
<div
class="radio-group text-default"
appBoxRow
name="TypeOptions"
*ngFor="let o of typeOptions"
>
<input
type="radio"
class="radio"
[(ngModel)]="type"
name="Type"
id="type_{{ o.value }}"
[value]="o.value"
(change)="typeChanged()"
[checked]="type === o.value"
/>
<label class="unstyled" for="type_{{ o.value }}">
{{ o.name }}
</label>
</div>
</div>
</div>
</div>
<ng-container *ngIf="type === 'password'">
<div class="box">
<h2 class="box-header">
<button type="button" (click)="toggleOptions()" [attr.aria-expanded]="showOptions">
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-right': !showOptions, 'bwi-angle-down': showOptions }"
></i>
{{ "options" | i18n }}
</button>
</h2>
<div class="box-content condensed" [hidden]="!showOptions">
<div
class="box-content-row box-content-row-radio"
role="radiogroup"
aria-labelledby="passwordTypeHeading"
>
<label id="passwordTypeHeading" class="radio-header">{{
"passwordType" | i18n
}}</label>
<div
class="radio-group text-default"
appBoxRow
name="PassTypeOptions"
*ngFor="let o of passTypeOptions"
>
<input
type="radio"
class="radio"
[(ngModel)]="passwordOptions.type"
name="PasswordType"
id="passwordType_{{ o.value }}"
[value]="o.value"
(change)="savePasswordOptions()"
[checked]="passwordOptions.type === o.value"
/>
<label class="unstyled" for="passwordType_{{ o.value }}">
{{ o.name }}
</label>
</div>
</div>
</div>
</div>
<div class="box" [hidden]="!showOptions" *ngIf="passwordOptions.type === 'passphrase'">
<div class="box-content condensed">
<div class="box-content-row box-content-row-input" appBoxRow>
<label for="num-words">{{ "numWords" | i18n }}</label>
<input
id="num-words"
type="number"
min="3"
max="20"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.numWords"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
<label for="word-separator">{{ "wordSeparator" | i18n }}</label>
<input
id="word-separator"
type="text"
maxlength="1"
(input)="savePasswordOptions()"
[(ngModel)]="passwordOptions.wordSeparator"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="capitalize">{{ "capitalize" | i18n }}</label>
<input
id="capitalize"
type="checkbox"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.capitalize"
[disabled]="enforcedPasswordPolicyOptions?.capitalize"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="include-number">{{ "includeNumber" | i18n }}</label>
<input
id="include-number"
type="checkbox"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.includeNumber"
[disabled]="enforcedPasswordPolicyOptions?.includeNumber"
/>
</div>
</div>
</div>
<ng-container *ngIf="passwordOptions.type === 'password'">
<div class="box" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row box-content-row-slider" appBoxRow>
<label for="length">{{ "length" | i18n }}</label>
<input
id="length"
type="number"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(blur)="savePasswordOptions()"
/>
<input
id="lengthRange"
type="range"
[min]="passwordOptions.minLength"
max="128"
step="1"
[(ngModel)]="passwordOptions.length"
(change)="sliderChanged()"
(input)="sliderInput()"
attr.aria-label="{{ 'length' | i18n }}"
tabindex="-1"
/>
</div>
<div class="box-content-row" appBoxRow>
<span>{{ "passwordMinLength" | i18n }}</span>
<span class="txt-right">{{ passwordOptions.minLength }}</span>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label>
<input
id="uppercase"
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useUppercase"
[(ngModel)]="passwordOptions.uppercase"
attr.aria-label="{{ 'uppercase' | i18n }}"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="lowercase">a-z</label>
<input
id="lowercase"
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useLowercase"
[(ngModel)]="passwordOptions.lowercase"
attr.aria-label="{{ 'lowercase' | i18n }}"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="numbers">0-9</label>
<input
id="numbers"
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
attr.aria-label="{{ 'numbers' | i18n }}"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="special">!&#64;#$%^&*</label>
<input
id="special"
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
[ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>
</div>
</div>
</div>
<div class="box" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row box-content-row-input" appBoxRow>
<label for="min-number">{{ "minNumbers" | i18n }}</label>
<input
id="min-number"
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
<label for="min-special">{{ "minSpecial" | i18n }}</label>
<input
id="min-special"
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="ambiguous">{{ "ambiguous" | i18n }}</label>
<input
id="ambiguous"
type="checkbox"
(change)="savePasswordOptions()"
[(ngModel)]="avoidAmbiguous"
/>
</div>
</div>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="type === 'username'">
<div class="box">
<h2 class="box-header">
<button type="button" (click)="toggleOptions()" [attr.aria-expanded]="showOptions">
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-right': !showOptions, 'bwi-angle-down': showOptions }"
></i>
{{ "options" | i18n }}
</button>
</h2>
<div class="box-content condensed" [hidden]="!showOptions">
<div
class="box-content-row box-content-row-radio"
role="radiogroup"
aria-labelledby="usernameTypeHeading"
>
<label id="usernameTypeHeading" class="radio-header">
{{ "usernameType" | i18n }}
<a
href="#"
appStopClick
(click)="usernameTypesLearnMore()"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</label>
<div
class="radio-group align-start text-default"
appBoxRow
name="UsernameTypeOptions"
*ngFor="let o of usernameTypeOptions"
>
<input
type="radio"
class="radio"
[(ngModel)]="usernameOptions.type"
name="UsernameType"
id="usernameType_{{ o.value }}"
[value]="o.value"
(change)="saveUsernameOptions()"
[checked]="usernameOptions.type === o.value"
/>
<label class="unstyled" for="usernameType_{{ o.value }}">
{{ o.name }}
<small class="help-block" *ngIf="o.desc">{{ o.desc }}</small>
</label>
</div>
</div>
</div>
</div>
<div class="box" *ngIf="usernameOptions.type === 'forwarded'" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row" role="listbox" aria-labelledby="forwardTypeHeading">
<label id="forwardTypeHeading">{{ "service" | i18n }}</label>
<select
id="ForwardTypeDropdown"
name="ForwardType"
[(ngModel)]="usernameOptions.forwardedService"
(change)="saveUsernameOptions()"
>
<option *ngFor="let o of forwardOptions" [ngValue]="o.value" role="option">
{{ o.name }}
</option>
</select>
</div>
<ng-container *ngIf="usernameOptions.forwardedService === 'simplelogin'">
<div class="box-content-row" appBoxRow>
<label for="simplelogin-apikey">{{ "apiKey" | i18n }}</label>
<input
id="simplelogin-apikey"
type="password"
name="SimpleLoginApiKey"
[(ngModel)]="usernameOptions.forwardedSimpleLoginApiKey"
(blur)="saveUsernameOptions()"
/>
</div>
<div class="box-content-row" appBoxRow>
<label for="simplelogin-baseUrl">{{ "baseUrl" | i18n }}</label>
<input
id="simplelogin-baseUrl"
type="text"
name="SimpleLoginDomain"
[(ngModel)]="usernameOptions.forwardedSimpleLoginBaseUrl"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
<ng-container *ngIf="usernameOptions.forwardedService === 'duckduckgo'">
<div class="box-content-row" appBoxRow>
<label for="duckduckgo-apikey">{{ "apiKey" | i18n }}</label>
<input
id="duckduckgo-apikey"
type="password"
name="DuckDuckGoApiKey"
[(ngModel)]="usernameOptions.forwardedDuckDuckGoToken"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
<ng-container *ngIf="usernameOptions.forwardedService === 'anonaddy'">
<div class="box-content-row" appBoxRow>
<label for="anonaddy-accessToken">{{ "apiAccessToken" | i18n }}</label>
<input
id="anonaddy-accessToken"
type="password"
name="AnonAddyAccessToken"
[(ngModel)]="usernameOptions.forwardedAnonAddyApiToken"
(blur)="saveUsernameOptions()"
/>
</div>
<div class="box-content-row" appBoxRow>
<label for="anonaddy-domain">{{ "aliasDomain" | i18n }}</label>
<input
id="anonaddy-domain"
type="text"
name="AnonAddyDomain"
[(ngModel)]="usernameOptions.forwardedAnonAddyDomain"
(blur)="saveUsernameOptions()"
/>
</div>
<div class="box-content-row" appBoxRow>
<label for="anonaddy-baseUrl">{{ "baseUrl" | i18n }}</label>
<input
id="anonaddy-baseUrl"
type="text"
name="AnonAddyDomain"
[(ngModel)]="usernameOptions.forwardedAnonAddyBaseUrl"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
<ng-container *ngIf="usernameOptions.forwardedService === 'firefoxrelay'">
<div class="box-content-row" appBoxRow>
<label for="firefox-apikey">{{ "apiAccessToken" | i18n }}</label>
<input
id="firefox-apikey"
type="password"
name="FirefoxApiKey"
[(ngModel)]="usernameOptions.forwardedFirefoxApiToken"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
<ng-container *ngIf="usernameOptions.forwardedService === 'fastmail'">
<div class="box-content-row" appBoxRow>
<label for="fastmail-apiToken">{{ "apiAccessToken" | i18n }}</label>
<input
id="fastmail-apiToken"
type="password"
name="FastmailApiToken"
[(ngModel)]="usernameOptions.forwardedFastmailApiToken"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
<ng-container *ngIf="usernameOptions.forwardedService === 'forwardemail'">
<div class="box-content-row" appBoxRow>
<label for="forwardemail-accessToken">{{ "apiAccessToken" | i18n }}</label>
<input
id="forwardemail-accessToken"
type="password"
name="ForwardEmailAccessToken"
[(ngModel)]="usernameOptions.forwardedForwardEmailApiToken"
(blur)="saveUsernameOptions()"
/>
</div>
<div class="box-content-row" appBoxRow>
<label for="forwardemail-domain">{{ "aliasDomain" | i18n }}</label>
<input
id="forwardemail-domain"
type="text"
name="ForwardEmailDomain"
[(ngModel)]="usernameOptions.forwardedForwardEmailDomain"
(blur)="saveUsernameOptions()"
/>
</div>
</ng-container>
</div>
</div>
<div class="box" *ngIf="usernameOptions.type === 'subaddress'" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row" appBoxRow>
<label for="subaddress-email">{{ "emailAddress" | i18n }}</label>
<input
id="subaddress-email"
type="text"
name="SubaddressEmail"
[(ngModel)]="usernameOptions.subaddressEmail"
(blur)="saveUsernameOptions()"
/>
</div>
<div
class="box-content-row"
role="radiogroup"
aria-labelledby="subaddressTypeHeading"
*ngIf="subaddressOptions.length > 1"
>
<label id="subaddressTypeHeading" class="radio-header">{{ "type" | i18n }}</label>
<div class="radio-group text-default" appBoxRow *ngFor="let o of subaddressOptions">
<input
type="radio"
[(ngModel)]="usernameOptions.subaddressType"
name="SubaddressType"
id="subaddresstype_{{ o.value }}"
[value]="o.value"
(change)="saveUsernameOptions()"
[checked]="usernameOptions.subaddressType === o.value"
/>
<label for="subaddresstype_{{ o.value }}">
{{ o.name }}
</label>
</div>
</div>
<div class="box-content-row" appBoxRow *ngIf="usernameWebsite">
<label for="subaddress-website">{{ "website" | i18n }}</label>
<input
id="subaddress-website"
type="text"
name="SubaddressWebsite"
[value]="usernameOptions.website"
disabled
readonly
/>
</div>
</div>
</div>
<div class="box" *ngIf="usernameOptions.type === 'catchall'" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row" appBoxRow>
<label for="catchall-domain">{{ "domainName" | i18n }}</label>
<input
id="catchall-domain"
type="text"
name="CatchallDomain"
[(ngModel)]="usernameOptions.catchallDomain"
(blur)="saveUsernameOptions()"
/>
</div>
<div
class="box-content-row"
role="radiogroup"
aria-labelledby="catchallTypeHeading"
*ngIf="catchallOptions.length > 1"
>
<label id="catchallTypeHeading" class="radio-header">{{ "type" | i18n }}</label>
<div class="radio-group text-default" appBoxRow *ngFor="let o of catchallOptions">
<input
type="radio"
[(ngModel)]="usernameOptions.catchallType"
name="CatchallType"
id="catchalltype_{{ o.value }}"
[value]="o.value"
(change)="saveUsernameOptions()"
[checked]="usernameOptions.catchallType === o.value"
/>
<label for="catchalltype_{{ o.value }}">
{{ o.name }}
</label>
</div>
</div>
<div class="box-content-row" appBoxRow *ngIf="usernameWebsite">
<label for="catchall-website">{{ "website" | i18n }}</label>
<input
id="catchall-website"
type="text"
name="CatchallWebsite"
[value]="usernameOptions.website"
disabled
readonly
/>
</div>
</div>
</div>
<div class="box" *ngIf="usernameOptions.type === 'word'" [hidden]="!showOptions">
<div class="box-content condensed">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="capitalize">{{ "capitalize" | i18n }}</label>
<input
id="capitalize"
type="checkbox"
(change)="saveUsernameOptions()"
[(ngModel)]="usernameOptions.wordCapitalize"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="include-number">{{ "includeNumber" | i18n }}</label>
<input
id="include-number"
type="checkbox"
(change)="saveUsernameOptions()"
[(ngModel)]="usernameOptions.wordIncludeNumber"
/>
</div>
</div>
</div>
</ng-container>
</div>
<div class="modal-footer">
<button
type="button"
class="primary"
*ngIf="comingFromAddEdit"
(click)="select()"
appA11yTitle="{{ 'select' | i18n }}"
>
<i class="bwi bwi-lg bwi-fw bwi-check" aria-hidden="true"></i>
</button>
<button type="button" data-dismiss="modal">
{{ (comingFromAddEdit ? "cancel" : "close") | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,90 +0,0 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { ToastService } from "@bitwarden/components";
import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import { GeneratorComponent } from "./generator.component";
describe("GeneratorComponent", () => {
let component: GeneratorComponent;
let fixture: ComponentFixture<GeneratorComponent>;
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
beforeEach(() => {
platformUtilsServiceMock = mock<PlatformUtilsService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
declarations: [GeneratorComponent, I18nPipe],
providers: [
{
provide: PasswordGenerationServiceAbstraction,
useValue: mock<PasswordGenerationServiceAbstraction>(),
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mock<UsernameGenerationServiceAbstraction>(),
},
{
provide: PlatformUtilsService,
useValue: platformUtilsServiceMock,
},
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{
provide: ActivatedRoute,
useValue: mock<ActivatedRoute>(),
},
{
provide: LogService,
useValue: mock<LogService>(),
},
{
provide: CipherService,
useValue: mock<CipherService>(),
},
{
provide: AccountService,
useValue: mock<AccountService>(),
},
{
provide: ToastService,
useValue: mock<ToastService>(),
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GeneratorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("usernameTypesLearnMore()", () => {
it("should call platformUtilsService.launchUri() once", () => {
component.usernameTypesLearnMore();
expect(platformUtilsServiceMock.launchUri).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,48 +0,0 @@
import { Component, NgZone } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
@Component({
selector: "app-generator",
templateUrl: "generator.component.html",
})
export class GeneratorComponent extends BaseGeneratorComponent {
constructor(
passwordGenerationService: PasswordGenerationServiceAbstraction,
usernameGenerationService: UsernameGenerationServiceAbstraction,
accountService: AccountService,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
route: ActivatedRoute,
ngZone: NgZone,
logService: LogService,
toastService: ToastService,
) {
super(
passwordGenerationService,
usernameGenerationService,
platformUtilsService,
accountService,
i18nService,
logService,
route,
ngZone,
window,
toastService,
);
}
usernameTypesLearnMore() {
this.platformUtilsService.launchUri("https://bitwarden.com/help/generator/#username-types");
}
}

View File

@@ -4,7 +4,7 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components";
import { ImportComponent } from "@bitwarden/importer/ui";
import { ImportComponent } from "@bitwarden/importer-ui";
@Component({
templateUrl: "import-desktop.component.html",

View File

@@ -1,52 +0,0 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="passwordGenHistoryTitle">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body">
<div class="box">
<h1 class="box-header" id="passwordGenHistoryTitle">
{{ "passwordHistory" | i18n }}
</h1>
<div class="box-content condensed">
<div class="box-content-row box-content-row-flex" *ngFor="let h of history">
<div class="row-main">
<div
class="password-wrapper monospaced"
[appCopyText]="h.password"
[innerHTML]="h.password | colorPassword"
></div>
<span class="detail">{{ h.date | date: "medium" }}</span>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy(h.password)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="box-content-row" *ngIf="!history.length">
{{ "noPasswordsInList" | i18n }}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" data-dismiss="modal">{{ "close" | i18n }}</button>
<div class="right">
<button
type="button"
(click)="clear()"
class="danger"
appA11yTitle="{{ 'clear' | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,22 +0,0 @@
import { Component } from "@angular/core";
import { PasswordGeneratorHistoryComponent as BasePasswordGeneratorHistoryComponent } from "@bitwarden/angular/tools/generator/components/password-generator-history.component";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@Component({
selector: "app-password-generator-history",
templateUrl: "password-generator-history.component.html",
})
export class PasswordGeneratorHistoryComponent extends BasePasswordGeneratorHistoryComponent {
constructor(
passwordGenerationService: PasswordGenerationServiceAbstraction,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
toastService: ToastService,
) {
super(passwordGenerationService, platformUtilsService, i18nService, window, toastService);
}
}

View File

@@ -5,6 +5,7 @@ import { Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -52,6 +53,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
sendApiService: SendApiService,
dialogService: DialogService,
toastService: ToastService,
accountService: AccountService,
) {
super(
sendService,
@@ -65,6 +67,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro
sendApiService,
dialogService,
toastService,
accountService,
);
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.searchText$.subscribe((searchText) => {

View File

@@ -1,80 +0,0 @@
<form id="lock-page" (ngSubmit)="submit()">
<div class="content">
<p aria-hidden="true"><i class="bwi bwi-lock bwi-4x text-muted"></i></p>
<p>{{ "yourVaultIsLocked" | i18n }}</p>
<div class="box last">
<div class="box-content">
<div
class="box-content-row box-content-row-flex"
appBoxRow
*ngIf="pinEnabled || masterPasswordEnabled"
>
<div class="row-main" *ngIf="pinEnabled">
<label for="pin">{{ "pin" | i18n }}</label>
<input
id="pin"
type="{{ showPassword ? 'text' : 'password' }}"
name="PIN"
class="monospaced"
[(ngModel)]="pin"
required
appInputVerbatim
/>
</div>
<div class="row-main" *ngIf="masterPasswordEnabled && !pinEnabled">
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
<input
id="masterPassword"
type="{{ showPassword ? 'text' : 'password' }}"
name="MasterPassword"
aria-describedby="masterPasswordHelp"
class="monospaced"
[(ngModel)]="masterPassword"
required
appInputVerbatim
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
[attr.aria-pressed]="showPassword"
(click)="togglePassword()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
</div>
</div>
</div>
<div id="masterPasswordHelp" class="box-footer">
{{ "loggedInAsOn" | i18n: email : webVaultHostname }}
</div>
</div>
<div class="buttons with-rows">
<div class="buttons-row" *ngIf="supportsBiometric && biometricLock && biometricReady">
<button
type="button"
class="btn block"
[ngClass]="{ 'primary font-weight-bold': !pinEnabled && !masterPasswordEnabled }"
(click)="unlockBiometric()"
>
{{ biometricText | i18n }}
</button>
</div>
<div class="buttons-row">
<button type="submit" class="btn primary block" *ngIf="pinEnabled || masterPasswordEnabled">
<i class="bwi bwi-unlock" aria-hidden="true"></i> <b>{{ "unlock" | i18n }}</b>
</button>
<button type="button" class="btn block" (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</div>
</div>
</form>

View File

@@ -1,478 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import {
KdfConfigService,
KeyService,
BiometricsService as AbstractBiometricService,
BiometricStateService,
} from "@bitwarden/key-management";
import { BiometricsService } from "../key-management/biometrics/biometrics.service";
import { LockComponent } from "./lock.component";
// ipc mock global
const isWindowVisibleMock = jest.fn();
(global as any).ipc = {
platform: {
isWindowVisible: isWindowVisibleMock,
},
keyManagement: {
biometric: {
enabled: jest.fn(),
},
},
};
describe("LockComponent", () => {
let component: LockComponent;
let fixture: ComponentFixture<LockComponent>;
let stateServiceMock: MockProxy<StateService>;
let biometricStateService: MockProxy<BiometricStateService>;
let biometricsService: MockProxy<BiometricsService>;
let messagingServiceMock: MockProxy<MessagingService>;
let broadcasterServiceMock: MockProxy<BroadcasterService>;
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
let activatedRouteMock: MockProxy<ActivatedRoute>;
let mockMasterPasswordService: FakeMasterPasswordService;
let mockToastService: MockProxy<ToastService>;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(async () => {
stateServiceMock = mock<StateService>();
messagingServiceMock = mock<MessagingService>();
broadcasterServiceMock = mock<BroadcasterService>();
platformUtilsServiceMock = mock<PlatformUtilsService>();
mockToastService = mock<ToastService>();
activatedRouteMock = mock<ActivatedRoute>();
activatedRouteMock.queryParams = mock<ActivatedRoute["queryParams"]>();
mockMasterPasswordService = new FakeMasterPasswordService();
biometricStateService = mock();
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
biometricStateService.promptAutomatically$ = of(false);
biometricStateService.promptCancelled$ = of(false);
await TestBed.configureTestingModule({
declarations: [LockComponent, I18nPipe],
providers: [
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{
provide: PlatformUtilsService,
useValue: platformUtilsServiceMock,
},
{
provide: MessagingService,
useValue: messagingServiceMock,
},
{
provide: KeyService,
useValue: mock<KeyService>(),
},
{
provide: VaultTimeoutService,
useValue: mock<VaultTimeoutService>(),
},
{
provide: VaultTimeoutSettingsService,
useValue: mock<VaultTimeoutSettingsService>(),
},
{
provide: EnvironmentService,
useValue: mock<EnvironmentService>(),
},
{
provide: StateService,
useValue: stateServiceMock,
},
{
provide: ApiService,
useValue: mock<ApiService>(),
},
{
provide: ActivatedRoute,
useValue: activatedRouteMock,
},
{
provide: BroadcasterService,
useValue: broadcasterServiceMock,
},
{
provide: PolicyApiServiceAbstraction,
useValue: mock<PolicyApiServiceAbstraction>(),
},
{
provide: InternalPolicyService,
useValue: mock<InternalPolicyService>(),
},
{
provide: PasswordStrengthServiceAbstraction,
useValue: mock<PasswordStrengthServiceAbstraction>(),
},
{
provide: LogService,
useValue: mock<LogService>(),
},
{
provide: DialogService,
useValue: mock<DialogService>(),
},
{
provide: DeviceTrustServiceAbstraction,
useValue: mock<DeviceTrustServiceAbstraction>(),
},
{
provide: UserVerificationService,
useValue: mock<UserVerificationService>(),
},
{
provide: PinServiceAbstraction,
useValue: mock<PinServiceAbstraction>(),
},
{
provide: BiometricStateService,
useValue: biometricStateService,
},
{
provide: AbstractBiometricService,
useValue: biometricsService,
},
{
provide: AccountService,
useValue: accountService,
},
{
provide: AuthService,
useValue: mock(),
},
{
provide: KdfConfigService,
useValue: mock<KdfConfigService>(),
},
{
provide: SyncService,
useValue: mock<SyncService>(),
},
{
provide: ToastService,
useValue: mockToastService,
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(LockComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
jest.resetAllMocks();
});
describe("ngOnInit", () => {
it("should call super.ngOnInit() once", async () => {
const superNgOnInitSpy = jest.spyOn(BaseLockComponent.prototype, "ngOnInit");
await component.ngOnInit();
expect(superNgOnInitSpy).toHaveBeenCalledTimes(1);
});
it('should set "autoPromptBiometric" to true if "biometricState.promptAutomatically$" resolves to true', async () => {
biometricStateService.promptAutomatically$ = of(true);
await component.ngOnInit();
expect(component["autoPromptBiometric"]).toBe(true);
});
it('should set "autoPromptBiometric" to false if "biometricState.promptAutomatically$" resolves to false', async () => {
biometricStateService.promptAutomatically$ = of(false);
await component.ngOnInit();
expect(component["autoPromptBiometric"]).toBe(false);
});
it('should set "biometricReady" to true if "stateService.getBiometricReady()" resolves to true', async () => {
component["canUseBiometric"] = jest.fn().mockResolvedValue(true);
await component.ngOnInit();
expect(component["biometricReady"]).toBe(true);
});
it('should set "biometricReady" to false if "stateService.getBiometricReady()" resolves to false', async () => {
component["canUseBiometric"] = jest.fn().mockResolvedValue(false);
await component.ngOnInit();
expect(component["biometricReady"]).toBe(false);
});
it("should call displayBiometricUpdateWarning", async () => {
component["displayBiometricUpdateWarning"] = jest.fn();
await component.ngOnInit();
expect(component["displayBiometricUpdateWarning"]).toHaveBeenCalledTimes(1);
});
it("should call delayedAskForBiometric", async () => {
component["delayedAskForBiometric"] = jest.fn();
await component.ngOnInit();
expect(component["delayedAskForBiometric"]).toHaveBeenCalledTimes(1);
expect(component["delayedAskForBiometric"]).toHaveBeenCalledWith(500);
});
it("should call delayedAskForBiometric when queryParams change", async () => {
activatedRouteMock.queryParams = of({ promptBiometric: true });
component["delayedAskForBiometric"] = jest.fn();
await component.ngOnInit();
expect(component["delayedAskForBiometric"]).toHaveBeenCalledTimes(1);
expect(component["delayedAskForBiometric"]).toHaveBeenCalledWith(500);
});
it("should call messagingService.send", async () => {
await component.ngOnInit();
expect(messagingServiceMock.send).toHaveBeenCalledWith("getWindowIsFocused");
});
describe("broadcasterService.subscribe", () => {
it('should call onWindowHidden() when "broadcasterService.subscribe" is called with "windowHidden"', async () => {
component["onWindowHidden"] = jest.fn();
await component.ngOnInit();
broadcasterServiceMock.subscribe.mock.calls[0][1]({ command: "windowHidden" });
expect(component["onWindowHidden"]).toHaveBeenCalledTimes(1);
});
it('should call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is false', async () => {
component["focusInput"] = jest.fn();
component["deferFocus"] = null;
await component.ngOnInit();
broadcasterServiceMock.subscribe.mock.calls[0][1]({
command: "windowIsFocused",
windowIsFocused: true,
} as any);
expect(component["deferFocus"]).toBe(false);
expect(component["focusInput"]).toHaveBeenCalledTimes(1);
});
it('should not call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is true', async () => {
component["focusInput"] = jest.fn();
component["deferFocus"] = null;
await component.ngOnInit();
broadcasterServiceMock.subscribe.mock.calls[0][1]({
command: "windowIsFocused",
windowIsFocused: false,
} as any);
expect(component["deferFocus"]).toBe(true);
expect(component["focusInput"]).toHaveBeenCalledTimes(0);
});
it('should call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is true and deferFocus is true', async () => {
component["focusInput"] = jest.fn();
component["deferFocus"] = true;
await component.ngOnInit();
broadcasterServiceMock.subscribe.mock.calls[0][1]({
command: "windowIsFocused",
windowIsFocused: true,
} as any);
expect(component["deferFocus"]).toBe(false);
expect(component["focusInput"]).toHaveBeenCalledTimes(1);
});
it('should not call focusInput() when "broadcasterService.subscribe" is called with "windowIsFocused" is false and deferFocus is true', async () => {
component["focusInput"] = jest.fn();
component["deferFocus"] = true;
await component.ngOnInit();
broadcasterServiceMock.subscribe.mock.calls[0][1]({
command: "windowIsFocused",
windowIsFocused: false,
} as any);
expect(component["deferFocus"]).toBe(true);
expect(component["focusInput"]).toHaveBeenCalledTimes(0);
});
});
});
describe("ngOnDestroy", () => {
it("should call super.ngOnDestroy()", () => {
const superNgOnDestroySpy = jest.spyOn(BaseLockComponent.prototype, "ngOnDestroy");
component.ngOnDestroy();
expect(superNgOnDestroySpy).toHaveBeenCalledTimes(1);
});
it("should call broadcasterService.unsubscribe()", () => {
component.ngOnDestroy();
expect(broadcasterServiceMock.unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe("focusInput", () => {
it('should call "focus" on #pin input if pinEnabled is true', () => {
component["pinEnabled"] = true;
global.document.getElementById = jest.fn().mockReturnValue({ focus: jest.fn() });
component["focusInput"]();
expect(global.document.getElementById).toHaveBeenCalledWith("pin");
});
it('should call "focus" on #masterPassword input if pinEnabled is false', () => {
component["pinEnabled"] = false;
global.document.getElementById = jest.fn().mockReturnValue({ focus: jest.fn() });
component["focusInput"]();
expect(global.document.getElementById).toHaveBeenCalledWith("masterPassword");
});
});
describe("delayedAskForBiometric", () => {
beforeEach(() => {
component["supportsBiometric"] = true;
component["autoPromptBiometric"] = true;
});
it('should wait for "delay" milliseconds', fakeAsync(async () => {
const delaySpy = jest.spyOn(global, "setTimeout");
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(4000);
component["biometricAsked"] = false;
tick(1000);
component["biometricAsked"] = true;
expect(delaySpy).toHaveBeenCalledWith(expect.any(Function), 5000);
}));
it('should return; if "params" is defined and "params.promptBiometric" is false', fakeAsync(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000, { promptBiometric: false });
tick(5000);
expect(component["biometricAsked"]).toBe(false);
}));
it('should not return; if "params" is defined and "params.promptBiometric" is true', fakeAsync(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000, { promptBiometric: true });
tick(5000);
expect(component["biometricAsked"]).toBe(true);
}));
it('should not return; if "params" is undefined', fakeAsync(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["biometricAsked"]).toBe(true);
}));
it('should return; if "supportsBiometric" is false', fakeAsync(async () => {
component["supportsBiometric"] = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["biometricAsked"]).toBe(false);
}));
it('should return; if "autoPromptBiometric" is false', fakeAsync(async () => {
component["autoPromptBiometric"] = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["biometricAsked"]).toBe(false);
}));
it("should call unlockBiometric() if biometricAsked is false and window is visible", fakeAsync(async () => {
isWindowVisibleMock.mockResolvedValue(true);
component["unlockBiometric"] = jest.fn();
component["biometricAsked"] = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(1);
}));
it("should not call unlockBiometric() if biometricAsked is false and window is not visible", fakeAsync(async () => {
isWindowVisibleMock.mockResolvedValue(false);
component["unlockBiometric"] = jest.fn();
component["biometricAsked"] = false;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(0);
}));
it("should not call unlockBiometric() if biometricAsked is true", fakeAsync(async () => {
isWindowVisibleMock.mockResolvedValue(true);
component["unlockBiometric"] = jest.fn();
component["biometricAsked"] = true;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
component["delayedAskForBiometric"](5000);
tick(5000);
expect(component["unlockBiometric"]).toHaveBeenCalledTimes(0);
}));
});
describe("canUseBiometric", () => {
it("should call biometric.enabled with current active user", async () => {
await component["canUseBiometric"]();
expect(ipc.keyManagement.biometric.enabled).toHaveBeenCalledWith(mockUserId);
});
});
it('onWindowHidden() should set "showPassword" to false', () => {
component["showPassword"] = true;
component["onWindowHidden"]();
expect(component["showPassword"]).toBe(false);
});
});

View File

@@ -1,235 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map, switchMap } from "rxjs";
import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import {
KdfConfigService,
KeyService,
BiometricsService,
BiometricStateService,
} from "@bitwarden/key-management";
const BroadcasterSubscriptionId = "LockComponent";
@Component({
selector: "app-lock",
templateUrl: "lock.component.html",
})
export class LockComponent extends BaseLockComponent implements OnInit, OnDestroy {
private deferFocus: boolean = null;
protected biometricReady = false;
private biometricAsked = false;
private autoPromptBiometric = false;
private timerId: any;
constructor(
masterPasswordService: InternalMasterPasswordServiceAbstraction,
router: Router,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
keyService: KeyService,
vaultTimeoutService: VaultTimeoutService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
environmentService: EnvironmentService,
protected override stateService: StateService,
apiService: ApiService,
private route: ActivatedRoute,
private broadcasterService: BroadcasterService,
ngZone: NgZone,
policyApiService: PolicyApiServiceAbstraction,
policyService: InternalPolicyService,
passwordStrengthService: PasswordStrengthServiceAbstraction,
logService: LogService,
dialogService: DialogService,
deviceTrustService: DeviceTrustServiceAbstraction,
userVerificationService: UserVerificationService,
pinService: PinServiceAbstraction,
biometricStateService: BiometricStateService,
biometricsService: BiometricsService,
accountService: AccountService,
authService: AuthService,
kdfConfigService: KdfConfigService,
syncService: SyncService,
toastService: ToastService,
) {
super(
masterPasswordService,
router,
i18nService,
platformUtilsService,
messagingService,
keyService,
vaultTimeoutService,
vaultTimeoutSettingsService,
environmentService,
stateService,
apiService,
logService,
ngZone,
policyApiService,
policyService,
passwordStrengthService,
dialogService,
deviceTrustService,
userVerificationService,
pinService,
biometricStateService,
biometricsService,
accountService,
authService,
kdfConfigService,
syncService,
toastService,
);
}
async ngOnInit() {
await super.ngOnInit();
this.autoPromptBiometric = await firstValueFrom(
this.biometricStateService.promptAutomatically$,
);
this.biometricReady = await this.canUseBiometric();
await this.displayBiometricUpdateWarning();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.delayedAskForBiometric(500);
this.route.queryParams.pipe(switchMap((params) => this.delayedAskForBiometric(500, params)));
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowHidden":
this.onWindowHidden();
break;
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
// start background listener until destroyed on interval
this.timerId = setInterval(async () => {
this.supportsBiometric = await this.biometricsService.supportsBiometric();
this.biometricReady = await this.canUseBiometric();
}, 1000);
}
ngOnDestroy() {
super.ngOnDestroy();
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
clearInterval(this.timerId);
}
onWindowHidden() {
this.showPassword = false;
}
private async delayedAskForBiometric(delay: number, params?: any) {
await new Promise((resolve) => setTimeout(resolve, delay));
if (params && !params.promptBiometric) {
return;
}
if (!this.supportsBiometric || !this.autoPromptBiometric || this.biometricAsked) {
return;
}
if (await firstValueFrom(this.biometricStateService.promptCancelled$)) {
return;
}
this.biometricAsked = true;
if (await ipc.platform.isWindowVisible()) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.unlockBiometric();
}
}
private async canUseBiometric() {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
return await ipc.keyManagement.biometric.enabled(userId);
}
private focusInput() {
document.getElementById(this.pinEnabled ? "pin" : "masterPassword")?.focus();
}
private async displayBiometricUpdateWarning(): Promise<void> {
if (await firstValueFrom(this.biometricStateService.dismissedRequirePasswordOnStartCallout$)) {
return;
}
if (this.platformUtilsService.getDevice() !== DeviceType.WindowsDesktop) {
return;
}
if (await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)) {
const response = await this.dialogService.openSimpleDialog({
title: { key: "windowsBiometricUpdateWarningTitle" },
content: { key: "windowsBiometricUpdateWarning" },
type: "warning",
});
await this.biometricStateService.setRequirePasswordOnStart(response);
if (response) {
await this.biometricStateService.setPromptAutomatically(false);
}
this.supportsBiometric = await this.canUseBiometric();
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
}
}
get biometricText() {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}
}
}

View File

@@ -51,15 +51,15 @@ describe("DesktopLoginApprovalComponentService", () => {
it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => {
const title = "Log in requested";
const email = "test@bitwarden.com";
const message = `Confirm login attempt for ${email}`;
const message = `Confirm access attempt for ${email}`;
const closeText = "Close";
const loginApprovalComponent = { email } as LoginApprovalComponent;
i18nService.t.mockImplementation((key: string) => {
switch (key) {
case "logInRequested":
case "accountAccessRequested":
return title;
case "confirmLoginAtemptForMail":
case "confirmAccessAttempt":
return message;
case "close":
return closeText;

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular";
@@ -15,12 +13,12 @@ export class DesktopLoginApprovalComponentService
super();
}
async showLoginRequestedAlertIfWindowNotVisible(email: string): Promise<void> {
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
const isVisible = await ipc.platform.isWindowVisible();
if (!isVisible) {
await ipc.auth.loginRequest(
this.i18nService.t("logInRequested"),
this.i18nService.t("confirmLoginAtemptForMail", email),
this.i18nService.t("accountAccessRequested"),
this.i18nService.t("confirmAccessAttempt", email),
this.i18nService.t("close"),
);
}

View File

@@ -3,7 +3,9 @@ import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DefaultLoginComponentService } from "@bitwarden/auth/angular";
import { SsoUrlService } from "@bitwarden/auth/common";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import {
Environment,
@@ -41,8 +43,7 @@ describe("DesktopLoginComponentService", () => {
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let superLaunchSsoBrowserWindowSpy: jest.SpyInstance;
let ssoUrlService: MockProxy<SsoUrlService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -60,6 +61,8 @@ describe("DesktopLoginComponentService", () => {
ssoLoginService = mock<SsoLoginServiceAbstraction>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
ssoUrlService = mock<SsoUrlService>();
TestBed.configureTestingModule({
providers: [
@@ -74,6 +77,7 @@ describe("DesktopLoginComponentService", () => {
ssoLoginService,
i18nService,
toastService,
ssoUrlService,
),
},
{ provide: DefaultLoginComponentService, useExisting: DesktopLoginComponentService },
@@ -84,15 +88,11 @@ describe("DesktopLoginComponentService", () => {
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
{ provide: I18nService, useValue: i18nService },
{ provide: ToastService, useValue: toastService },
{ provide: SsoUrlService, useValue: ssoUrlService },
],
});
service = TestBed.inject(DesktopLoginComponentService);
superLaunchSsoBrowserWindowSpy = jest.spyOn(
DefaultLoginComponentService.prototype,
"launchSsoBrowserWindow",
);
});
afterEach(() => {
@@ -106,7 +106,7 @@ describe("DesktopLoginComponentService", () => {
expect(service).toBeTruthy();
});
describe("launchSsoBrowserWindow", () => {
describe("redirectToSso", () => {
// Array of all permutations of isAppImage, isSnapStore, and isDev
const permutations = [
[true, false, false], // Case 1: isAppImage true
@@ -125,36 +125,27 @@ describe("DesktopLoginComponentService", () => {
(global as any).ipc.platform.isSnapStore = isSnapStore;
(global as any).ipc.platform.isDev = isDev;
const email = "user@example.com";
const clientId = "desktop";
const codeChallenge = "testCodeChallenge";
const codeVerifier = "testCodeVerifier";
const email = "test@bitwarden.com";
const state = "testState";
const codeVerifierHash = new Uint8Array(64);
const codeVerifier = "testCodeVerifier";
const codeChallenge = "testCodeChallenge";
passwordGenerationService.generatePassword.mockResolvedValueOnce(state);
passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier);
cryptoFunctionService.hash.mockResolvedValueOnce(codeVerifierHash);
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge);
await service.launchSsoBrowserWindow(email, clientId);
await service.redirectToSsoLogin(email);
if (isAppImage || isSnapStore || isDev) {
expect(superLaunchSsoBrowserWindowSpy).not.toHaveBeenCalled();
// Assert that the standard logic is executed
expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email);
expect(passwordGenerationService.generatePassword).toHaveBeenCalledTimes(2);
expect(cryptoFunctionService.hash).toHaveBeenCalledWith(codeVerifier, "sha256");
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith(
codeChallenge,
state,
email,
);
} else {
// If all values are false, expect the super method to be called
expect(superLaunchSsoBrowserWindowSpy).toHaveBeenCalledWith(email, clientId);
expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state);
expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier);
expect(platformUtilsService.launchUri).toHaveBeenCalled();
}
});
});

View File

@@ -1,14 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular";
import { DESKTOP_SSO_CALLBACK, SsoUrlService } from "@bitwarden/auth/common";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@@ -25,6 +26,7 @@ export class DesktopLoginComponentService
protected ssoLoginService: SsoLoginServiceAbstraction,
protected i18nService: I18nService,
protected toastService: ToastService,
protected ssoUrlService: SsoUrlService,
) {
super(
cryptoFunctionService,
@@ -33,38 +35,52 @@ export class DesktopLoginComponentService
platformUtilsService,
ssoLoginService,
);
this.clientType = this.platformUtilsService.getClientType();
}
override async launchSsoBrowserWindow(email: string, clientId: "desktop"): Promise<void | null> {
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
return super.launchSsoBrowserWindow(email, clientId);
/**
* On the desktop, redirecting to the SSO login page is done via a new browser window, opened
* to the SSO component on the web client.
* @param email the email of the user trying to log in, used to look up the org SSO identifier
* @param state the state that will be used to verify the SSO login, which needs to be passed to the IdP
* @param codeChallenge the challenge that will be verified after the code is returned from the IdP, which needs to be passed to the IdP
*/
protected override async redirectToSso(
email: string,
state: string,
codeChallenge: string,
): Promise<void> {
// For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback
// Otherwise, we launch the SSO component in a browser window and wait for the callback
if (ipc.platform.isAppImage || ipc.platform.isSnapStore || ipc.platform.isDev) {
await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge);
} else {
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
const redirectUri = DESKTOP_SSO_CALLBACK;
const ssoWebAppUrl = this.ssoUrlService.buildSsoUrl(
webVaultUrl,
this.clientType,
redirectUri,
state,
codeChallenge,
email,
);
this.platformUtilsService.launchUri(ssoWebAppUrl);
}
}
// Save email for SSO
await this.ssoLoginService.setSsoEmail(email);
// Generate SSO params
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
// Save SSO params
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
private async initiateSsoThroughLocalhostCallback(
email: string,
state: string,
challenge: string,
): Promise<void> {
try {
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state);
await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
this.toastService.showToast({
variant: "error",

View File

@@ -50,7 +50,7 @@
</div>
<div class="sub-options">
<p class="no-margin">{{ "newAroundHere" | i18n }}</p>
<button type="button" class="text text-primary" [routerLink]="registerRoute$ | async">
<button type="button" class="text text-primary" routerLink="/signup">
{{ "createAccount" | i18n }}
</button>
</div>

View File

@@ -3,7 +3,7 @@
import { Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil, tap } from "rxjs";
import { Subject, firstValueFrom, takeUntil, tap } from "rxjs";
import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
@@ -11,11 +11,9 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
RegisterRouteService,
} from "@bitwarden/auth/common";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -77,8 +75,6 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
route: ActivatedRoute,
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
webAuthnLoginService: WebAuthnLoginServiceAbstraction,
registerRouteService: RegisterRouteService,
toastService: ToastService,
private configService: ConfigService,
) {
@@ -100,8 +96,6 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
route,
loginEmailService,
ssoLoginService,
webAuthnLoginService,
registerRouteService,
toastService,
);
this.onSuccessfulLogin = () => {
@@ -149,10 +143,11 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
// If the flag is turned ON, we must force a reload to ensure the correct UI is shown
if (flag) {
const qParams = await firstValueFrom(this.route.queryParams);
const uniqueQueryParams = {
...this.route.queryParams,
...qParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(),
};
@@ -162,7 +157,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
});
}
}),
takeUntil(this.destroy$),
takeUntil(this.componentDestroyed$),
)
.subscribe();
}
@@ -226,9 +221,10 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
return super.launchSsoBrowser(clientId, ssoRedirectUri);
}
const email = this.formGroup.controls.email.value;
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
await this.ssoLoginService.setSsoEmail(email);
// Generate necessary sso params
const passwordOptions: any = {
@@ -247,8 +243,11 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe
// Save sso params
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
try {
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state);
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state, email);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
this.platformUtilsService.showToast(
"error",

Some files were not shown because too many files have changed in this diff Show More