mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
Rework Desktop Biometrics (#5234)
This commit is contained in:
parent
4852992662
commit
830af7b06d
1
.github/whitelist-capital-letters.txt
vendored
1
.github/whitelist-capital-letters.txt
vendored
@ -27,7 +27,6 @@
|
||||
./libs/components/src/stories/Introduction.stories.mdx
|
||||
./libs/common/spec/web/services/webCryptoFunction.service.spec.ts
|
||||
./libs/common/spec/shared/interceptConsole.ts
|
||||
./libs/common/spec/models/domain/encString.spec.ts
|
||||
./libs/common/spec/models/domain/symmetricCryptoKey.spec.ts
|
||||
./libs/common/spec/models/domain/encArrayBuffer.spec.ts
|
||||
./libs/common/spec/matchers/toEqualBuffer.spec.ts
|
||||
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"cSpell.words": ["Popout", "Reprompt", "takeuntil"]
|
||||
"cSpell.words": ["Csprng", "Popout", "Reprompt", "takeuntil"]
|
||||
}
|
||||
|
783
apps/desktop/desktop_native/Cargo.lock
generated
783
apps/desktop/desktop_native/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -13,20 +13,29 @@ default=[]
|
||||
manual_test=[]
|
||||
|
||||
[dependencies]
|
||||
aes = "0.8.2"
|
||||
anyhow = "1.0"
|
||||
base64 = "0.21.0"
|
||||
cbc = { version = "0.1.2", features = ["alloc"] }
|
||||
napi = {version = "2.9.1", features = ["async"]}
|
||||
napi-derive = "2.9.1"
|
||||
rand = "0.8.5"
|
||||
retry = "2.0.0"
|
||||
scopeguard = "1.1.0"
|
||||
sha2 = "0.10.6"
|
||||
thiserror = "1.0.38"
|
||||
tokio = {version = "1.17.0", features = ["full"]}
|
||||
typenum = "1.16.0"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.0.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
widestring = "0.5.1"
|
||||
windows = {version = "0.39.0", features = [
|
||||
windows = {version = "0.48.0", features = [
|
||||
"Foundation",
|
||||
"Security_Credentials_UI",
|
||||
"Security_Cryptography",
|
||||
"Storage_Streams",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security_Credentials",
|
||||
|
20
apps/desktop/desktop_native/index.d.ts
vendored
20
apps/desktop/desktop_native/index.d.ts
vendored
@ -16,4 +16,24 @@ export namespace passwords {
|
||||
export namespace biometrics {
|
||||
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
|
||||
export function available(): Promise<boolean>
|
||||
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
|
||||
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<string>
|
||||
/**
|
||||
* Derives key material from biometric data. Returns a string encoded with a
|
||||
* base64 encoded key and the base64 encoded challenge used to create it
|
||||
* separated by a `|` character.
|
||||
*
|
||||
* If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated.
|
||||
*
|
||||
* `format!("<key_base64>|<iv_base64>")`
|
||||
*/
|
||||
export function deriveKeyMaterial(iv?: string | undefined | null): Promise<OsDerivedKey>
|
||||
export interface KeyMaterial {
|
||||
osKeyPartB64: string
|
||||
clientKeyPartB64?: string
|
||||
}
|
||||
export interface OsDerivedKey {
|
||||
keyB64: string
|
||||
ivB64: string
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,38 @@
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
pub fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
use crate::biometrics::{KeyMaterial, OsDerivedKey};
|
||||
|
||||
pub fn available() -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
/// The MacOS implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
|
||||
impl super::BiometricTrait for Biometric {
|
||||
fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn available() -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn derive_key_material(_iv_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_key_material: Option<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_secret: &str,
|
||||
_key_material: Option<super::KeyMaterial>,
|
||||
_iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,28 @@
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod biometric;
|
||||
pub use biometric::*;
|
||||
|
||||
pub use biometric::Biometric;
|
||||
|
||||
use crate::biometrics::{KeyMaterial, OsDerivedKey};
|
||||
|
||||
pub trait BiometricTrait {
|
||||
fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
|
||||
fn available() -> Result<bool>;
|
||||
fn derive_key_material(secret: Option<&str>) -> Result<OsDerivedKey>;
|
||||
fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String>;
|
||||
fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
@ -1,9 +1,38 @@
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
pub fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
use crate::biometrics::{KeyMaterial, OsDerivedKey};
|
||||
|
||||
pub fn available() -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
/// The Unix implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
|
||||
impl super::BiometricTrait for Biometric {
|
||||
fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn available() -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn derive_key_material(_iv_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_key_material: Option<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_secret: &str,
|
||||
_key_material: Option<KeyMaterial>,
|
||||
_iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,21 @@
|
||||
use anyhow::Result;
|
||||
use std::str::FromStr;
|
||||
|
||||
use aes::cipher::generic_array::GenericArray;
|
||||
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::{
|
||||
h,
|
||||
core::{factory, HSTRING},
|
||||
Foundation::IAsyncOperation,
|
||||
Security::Credentials::UI::*,
|
||||
Security::{
|
||||
Credentials::{
|
||||
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, UI::*,
|
||||
},
|
||||
Cryptography::CryptographicBuffer,
|
||||
},
|
||||
Win32::{
|
||||
Foundation::HWND,
|
||||
System::WinRT::IUserConsentVerifierInterop,
|
||||
@ -11,40 +24,195 @@ use windows::{
|
||||
keybd_event, GetAsyncKeyState, SetFocus, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP,
|
||||
VK_MENU,
|
||||
},
|
||||
WindowsAndMessaging::SetForegroundWindow,
|
||||
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
|
||||
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
|
||||
let window = HWND(h);
|
||||
use crate::{
|
||||
biometrics::{KeyMaterial, OsDerivedKey},
|
||||
crypto::{self, CipherString},
|
||||
};
|
||||
|
||||
// The Windows Hello prompt is displayed inside the application window. For best result we
|
||||
// should set the window to the foreground and focus it.
|
||||
set_focus(window);
|
||||
/// The Windows OS implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
|
||||
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let operation: IAsyncOperation<UserConsentVerificationResult> =
|
||||
unsafe { interop.RequestVerificationForWindowAsync(window, &HSTRING::from(message))? };
|
||||
let result = operation.get()?;
|
||||
impl super::BiometricTrait for Biometric {
|
||||
fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
|
||||
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
|
||||
let window = HWND(h);
|
||||
|
||||
match result {
|
||||
UserConsentVerificationResult::Verified => Ok(true),
|
||||
_ => Ok(false),
|
||||
// The Windows Hello prompt is displayed inside the application window. For best result we
|
||||
// should set the window to the foreground and focus it.
|
||||
set_focus(window);
|
||||
|
||||
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let operation: IAsyncOperation<UserConsentVerificationResult> =
|
||||
unsafe { interop.RequestVerificationForWindowAsync(window, &HSTRING::from(message))? };
|
||||
let result = operation.get()?;
|
||||
|
||||
match result {
|
||||
UserConsentVerificationResult::Verified => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn available() -> Result<bool> {
|
||||
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
|
||||
|
||||
match ucv_available {
|
||||
UserConsentVerifierAvailability::Available => Ok(true),
|
||||
UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the symmetric encryption key from the Windows Hello signature.
|
||||
///
|
||||
/// This works by signing a static challenge string with Windows Hello protected key store. The
|
||||
/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the
|
||||
/// Windows Hello protected keys.
|
||||
///
|
||||
/// Windows will only sign the challenge if the user has successfully authenticated with Windows,
|
||||
/// ensuring user presence.
|
||||
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
let challenge: [u8; 16] = match challenge_str {
|
||||
Some(challenge_str) => base64_engine
|
||||
.decode(challenge_str)?
|
||||
.try_into()
|
||||
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
|
||||
None => random_challenge(),
|
||||
};
|
||||
let bitwarden = h!("Bitwarden");
|
||||
|
||||
let result = KeyCredentialManager::RequestCreateAsync(
|
||||
&bitwarden,
|
||||
KeyCredentialCreationOption::FailIfExists,
|
||||
)?
|
||||
.get()?;
|
||||
|
||||
let result = match result.Status()? {
|
||||
KeyCredentialStatus::CredentialAlreadyExists => {
|
||||
KeyCredentialManager::OpenAsync(&bitwarden)?.get()?
|
||||
}
|
||||
KeyCredentialStatus::Success => result,
|
||||
_ => return Err(anyhow!("Failed to create key credential")),
|
||||
};
|
||||
|
||||
let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?;
|
||||
let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?;
|
||||
focus_security_prompt()?;
|
||||
let signature = async_operation.get()?;
|
||||
|
||||
if signature.Status()? != KeyCredentialStatus::Success {
|
||||
return Err(anyhow!("Failed to sign data"));
|
||||
}
|
||||
|
||||
let signature_buffer = signature.Result()?;
|
||||
let mut signature_value =
|
||||
windows::core::Array::<u8>::with_len(signature_buffer.Length().unwrap() as usize);
|
||||
CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?;
|
||||
|
||||
let key = Sha256::digest(&*signature_value);
|
||||
let key_b64 = base64_engine.encode(&key);
|
||||
let iv_b64 = base64_engine.encode(&challenge);
|
||||
Ok(OsDerivedKey { key_b64, iv_b64 })
|
||||
}
|
||||
|
||||
fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
let key_material = key_material.ok_or(anyhow!(
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
|
||||
crate::password::set_password(service, account, &encrypted_secret)?;
|
||||
Ok(encrypted_secret)
|
||||
}
|
||||
|
||||
fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
let key_material = key_material.ok_or(anyhow!(
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let encrypted_secret = crate::password::get_password(service, account)?;
|
||||
match CipherString::from_str(&encrypted_secret) {
|
||||
Ok(secret) => {
|
||||
// If the secret is a CipherString, it is encrypted and we need to decrypt it.
|
||||
let secret = decrypt(&secret, &key_material)?;
|
||||
return Ok(secret);
|
||||
}
|
||||
Err(_) => {
|
||||
// If the secret is not a CipherString, it is not encrypted and we can return it
|
||||
// directly.
|
||||
return Ok(encrypted_secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available() -> Result<bool> {
|
||||
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
|
||||
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
||||
let iv = base64_engine
|
||||
.decode(iv_b64)?
|
||||
.try_into()
|
||||
.map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?;
|
||||
|
||||
match ucv_available {
|
||||
UserConsentVerifierAvailability::Available => Ok(true),
|
||||
UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc
|
||||
_ => Ok(false),
|
||||
let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?;
|
||||
|
||||
Ok(encrypted.to_string())
|
||||
}
|
||||
|
||||
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()?)?;
|
||||
|
||||
Ok(String::from_utf8(decrypted)?)
|
||||
} else {
|
||||
Err(anyhow!("Invalid cipher string"))
|
||||
}
|
||||
}
|
||||
|
||||
fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut challenge);
|
||||
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 hwnd.0 != 0 {
|
||||
set_focus(hwnd);
|
||||
return retry::OperationResult::Ok(());
|
||||
}
|
||||
retry::OperationResult::Retry(())
|
||||
}
|
||||
|
||||
let class_name = windows::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;
|
||||
|
||||
@ -70,14 +238,49 @@ fn set_focus(window: HWND) {
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyMaterial {
|
||||
fn digest_material(&self) -> String {
|
||||
match self.client_key_part_b64.as_deref() {
|
||||
Some(client_key_part_b64) => format!("{}|{}", self.os_key_part_b64, client_key_part_b64),
|
||||
None => self.os_key_part_b64.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
|
||||
Ok(Sha256::digest(self.digest_material()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::biometric::BiometricTrait;
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
fn test_derive_key_material() {
|
||||
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
|
||||
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
|
||||
let key = base64_engine.decode(result.key_b64).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert_eq!(result.iv_b64, iv_input)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
fn test_derive_key_material_no_iv() {
|
||||
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
|
||||
let key = base64_engine.decode(result.key_b64).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
let iv = base64_engine.decode(result.iv_b64).unwrap();
|
||||
assert_eq!(iv.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
fn test_prompt() {
|
||||
prompt(
|
||||
<Biometric as BiometricTrait>::prompt(
|
||||
vec![0, 0, 0, 0, 0, 0, 0, 0],
|
||||
String::from("Hello from Rust"),
|
||||
)
|
||||
@ -87,6 +290,145 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(feature = "manual_test")]
|
||||
fn test_available() {
|
||||
assert!(available().unwrap())
|
||||
assert!(<Biometric as BiometricTrait>::available().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt() {
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned();
|
||||
let secret = encrypt("secret", &key_material, &iv_b64)
|
||||
.unwrap()
|
||||
.parse::<CipherString>()
|
||||
.unwrap();
|
||||
|
||||
match secret {
|
||||
CipherString::AesCbc256_B64 { iv, data: _ } => {
|
||||
assert_eq!(iv_b64, base64_engine.encode(&iv));
|
||||
}
|
||||
_ => panic!("Invalid cipher string"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt() {
|
||||
let secret =
|
||||
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_handles_unencrypted_secret() {
|
||||
scopeguard::defer! {
|
||||
crate::password::delete_password("test", "test").unwrap();
|
||||
}
|
||||
let test = "test";
|
||||
let secret = "password";
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, secret).unwrap();
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.unwrap();
|
||||
assert_eq!(result, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_biometric_secret_handles_encrypted_secret() {
|
||||
scopeguard::defer! {
|
||||
crate::password::delete_password("test", "test").unwrap();
|
||||
}
|
||||
let test = "test";
|
||||
let secret =
|
||||
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
|
||||
let key_material = KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
};
|
||||
crate::password::set_password(test, test, &secret.to_string()).unwrap();
|
||||
|
||||
let result =
|
||||
<Biometric as BiometricTrait>::get_biometric_secret(test, test, Some(key_material))
|
||||
.unwrap();
|
||||
assert_eq!(result, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_biometric_secret_requires_key() {
|
||||
let result = <Biometric as BiometricTrait>::set_biometric_secret("", "", "", None, "");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
);
|
||||
}
|
||||
|
||||
fn key_material() -> KeyMaterial {
|
||||
KeyMaterial {
|
||||
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
|
||||
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_material_produces_valid_key() {
|
||||
let result = key_material().derive_key().unwrap();
|
||||
assert_eq!(result.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_material_uses_os_part() {
|
||||
let mut key_material = key_material();
|
||||
let result = key_material.derive_key().unwrap();
|
||||
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
|
||||
let result2 = key_material.derive_key().unwrap();
|
||||
assert_ne!(result, result2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_material_uses_client_part() {
|
||||
let mut key_material = key_material();
|
||||
let result = key_material.derive_key().unwrap();
|
||||
key_material.client_key_part_b64 =
|
||||
Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned());
|
||||
let result2 = key_material.derive_key().unwrap();
|
||||
assert_ne!(result, result2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_material_produces_consistent_os_only_key() {
|
||||
let mut key_material = key_material();
|
||||
key_material.client_key_part_b64 = None;
|
||||
let result = key_material.derive_key().unwrap();
|
||||
assert_eq!(result, [81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218, 237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246].into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_material_produces_unique_os_only_key() {
|
||||
let mut key_material = key_material();
|
||||
key_material.client_key_part_b64 = None;
|
||||
let result = key_material.derive_key().unwrap();
|
||||
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
|
||||
let result2 = key_material.derive_key().unwrap();
|
||||
assert_ne!(result, result2);
|
||||
}
|
||||
}
|
||||
|
212
apps/desktop/desktop_native/src/crypto/cipher_string.rs
Normal file
212
apps/desktop/desktop_native/src/crypto/cipher_string.rs
Normal file
@ -0,0 +1,212 @@
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||
|
||||
use crate::error::{CSParseError, Error};
|
||||
|
||||
#[allow(unused, non_camel_case_types)]
|
||||
pub enum CipherString {
|
||||
// 0
|
||||
AesCbc256_B64 {
|
||||
iv: [u8; 16],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 1
|
||||
AesCbc128_HmacSha256_B64 {
|
||||
iv: [u8; 16],
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 2
|
||||
AesCbc256_HmacSha256_B64 {
|
||||
iv: [u8; 16],
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 3
|
||||
Rsa2048_OaepSha256_B64 {
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 4
|
||||
Rsa2048_OaepSha1_B64 {
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 5
|
||||
Rsa2048_OaepSha256_HmacSha256_B64 {
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 6
|
||||
Rsa2048_OaepSha1_HmacSha256_B64 {
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
// We manually implement these to make sure we don't print any sensitive data
|
||||
impl std::fmt::Debug for CipherString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CipherString")
|
||||
.field("type", &self.enc_type_name())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_len_error(expected: usize) -> impl Fn(Vec<u8>) -> CSParseError {
|
||||
move |e: Vec<_>| CSParseError::InvalidBase64Length {
|
||||
expected,
|
||||
got: e.len(),
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CipherString {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (enc_type, data) = s.split_once('.').ok_or(CSParseError::NoType)?;
|
||||
|
||||
let parts: Vec<_> = data.split('|').collect();
|
||||
match (enc_type, parts.len()) {
|
||||
("0", 2) => {
|
||||
let iv_str = parts[0];
|
||||
let data_str = parts[1];
|
||||
|
||||
let iv = base64_engine
|
||||
.decode(iv_str)
|
||||
.map_err(CSParseError::InvalidBase64)?
|
||||
.try_into()
|
||||
.map_err(invalid_len_error(16))?;
|
||||
|
||||
let data = base64_engine
|
||||
.decode(data_str)
|
||||
.map_err(CSParseError::InvalidBase64)?;
|
||||
|
||||
Ok(CipherString::AesCbc256_B64 { iv, data })
|
||||
}
|
||||
|
||||
("1" | "2", 3) => {
|
||||
let iv_str = parts[0];
|
||||
let data_str = parts[1];
|
||||
let mac_str = parts[2];
|
||||
|
||||
let iv = base64_engine
|
||||
.decode(iv_str)
|
||||
.map_err(CSParseError::InvalidBase64)?
|
||||
.try_into()
|
||||
.map_err(invalid_len_error(16))?;
|
||||
|
||||
let mac = base64_engine
|
||||
.decode(mac_str)
|
||||
.map_err(CSParseError::InvalidBase64)?
|
||||
.try_into()
|
||||
.map_err(invalid_len_error(32))?;
|
||||
|
||||
let data = base64_engine
|
||||
.decode(data_str)
|
||||
.map_err(CSParseError::InvalidBase64)?;
|
||||
|
||||
if enc_type == "1" {
|
||||
Ok(CipherString::AesCbc128_HmacSha256_B64 { iv, mac, data })
|
||||
} else {
|
||||
Ok(CipherString::AesCbc256_HmacSha256_B64 { iv, mac, data })
|
||||
}
|
||||
}
|
||||
|
||||
("3" | "4", 1) => {
|
||||
let data = base64_engine
|
||||
.decode(data)
|
||||
.map_err(CSParseError::InvalidBase64)?;
|
||||
if enc_type == "3" {
|
||||
Ok(CipherString::Rsa2048_OaepSha256_B64 { data })
|
||||
} else {
|
||||
Ok(CipherString::Rsa2048_OaepSha1_B64 { data })
|
||||
}
|
||||
}
|
||||
("5" | "6", 2) => {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
(enc_type, parts) => Err(CSParseError::InvalidType {
|
||||
enc_type: enc_type.to_string(),
|
||||
parts,
|
||||
}
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CipherString {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}.", self.enc_type())?;
|
||||
|
||||
let mut parts = Vec::<&[u8]>::new();
|
||||
|
||||
match self {
|
||||
CipherString::AesCbc256_B64 { iv, data } => {
|
||||
parts.push(iv);
|
||||
parts.push(data);
|
||||
}
|
||||
CipherString::AesCbc128_HmacSha256_B64 { iv, mac, data } => {
|
||||
parts.push(iv);
|
||||
parts.push(data);
|
||||
parts.push(mac);
|
||||
}
|
||||
CipherString::AesCbc256_HmacSha256_B64 { iv, mac, data } => {
|
||||
parts.push(iv);
|
||||
parts.push(data);
|
||||
parts.push(mac);
|
||||
}
|
||||
CipherString::Rsa2048_OaepSha256_B64 { data } => {
|
||||
parts.push(data);
|
||||
}
|
||||
CipherString::Rsa2048_OaepSha1_B64 { data } => {
|
||||
parts.push(data);
|
||||
}
|
||||
CipherString::Rsa2048_OaepSha256_HmacSha256_B64 { mac, data } => {
|
||||
parts.push(data);
|
||||
parts.push(mac);
|
||||
}
|
||||
CipherString::Rsa2048_OaepSha1_HmacSha256_B64 { mac, data } => {
|
||||
parts.push(data);
|
||||
parts.push(mac);
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..parts.len() {
|
||||
if i == parts.len() - 1 {
|
||||
write!(f, "{}", base64_engine.encode(parts[i]))?;
|
||||
} else {
|
||||
write!(f, "{}|", base64_engine.encode(parts[i]))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CipherString {
|
||||
fn enc_type(&self) -> u8 {
|
||||
match self {
|
||||
CipherString::AesCbc256_B64 { .. } => 0,
|
||||
CipherString::AesCbc128_HmacSha256_B64 { .. } => 1,
|
||||
CipherString::AesCbc256_HmacSha256_B64 { .. } => 2,
|
||||
CipherString::Rsa2048_OaepSha256_B64 { .. } => 3,
|
||||
CipherString::Rsa2048_OaepSha1_B64 { .. } => 4,
|
||||
CipherString::Rsa2048_OaepSha256_HmacSha256_B64 { .. } => 5,
|
||||
CipherString::Rsa2048_OaepSha1_HmacSha256_B64 { .. } => 6,
|
||||
}
|
||||
}
|
||||
|
||||
fn enc_type_name(&self) -> &str {
|
||||
match self.enc_type() {
|
||||
0 => "AesCbc256_B64",
|
||||
1 => "AesCbc128_HmacSha256_B64",
|
||||
2 => "AesCbc256_HmacSha256_B64",
|
||||
3 => "Rsa2048_OaepSha256_B64",
|
||||
4 => "Rsa2048_OaepSha1_B64",
|
||||
5 => "Rsa2048_OaepSha256_HmacSha256_B64",
|
||||
6 => "Rsa2048_OaepSha1_HmacSha256_B64",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
39
apps/desktop/desktop_native/src/crypto/crypto.rs
Normal file
39
apps/desktop/desktop_native/src/crypto/crypto.rs
Normal file
@ -0,0 +1,39 @@
|
||||
//! Cryptographic primitives used in the SDK
|
||||
|
||||
use aes::cipher::{
|
||||
block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut,
|
||||
BlockEncryptMut, KeyIvInit,
|
||||
};
|
||||
|
||||
use crate::error::{CryptoError, Result};
|
||||
|
||||
use super::CipherString;
|
||||
|
||||
pub fn decrypt_aes256(
|
||||
iv: &[u8; 16],
|
||||
data: &Vec<u8>,
|
||||
key: GenericArray<u8, U32>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let iv = GenericArray::from_slice(iv);
|
||||
let mut data = data.clone();
|
||||
let decrypted_key_slice = cbc::Decryptor::<aes::Aes256>::new(&key, iv)
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut data)
|
||||
.map_err(|_| CryptoError::KeyDecrypt)?;
|
||||
|
||||
// Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, we truncate to the subslice length
|
||||
let decrypted_len = decrypted_key_slice.len();
|
||||
data.truncate(decrypted_len);
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn encrypt_aes256(
|
||||
data_dec: &[u8],
|
||||
iv: [u8; 16],
|
||||
key: GenericArray<u8, U32>,
|
||||
) -> Result<CipherString> {
|
||||
let data = cbc::Encryptor::<aes::Aes256>::new(&key, &iv.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(data_dec);
|
||||
|
||||
Ok(CipherString::AesCbc256_B64 { iv, data })
|
||||
}
|
5
apps/desktop/desktop_native/src/crypto/mod.rs
Normal file
5
apps/desktop/desktop_native/src/crypto/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub use cipher_string::*;
|
||||
pub use crypto::*;
|
||||
|
||||
mod cipher_string;
|
||||
mod crypto;
|
43
apps/desktop/desktop_native/src/error.rs
Normal file
43
apps/desktop/desktop_native/src/error.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Error parsing CipherString: {0}")]
|
||||
InvalidCipherString(#[from] CSParseError),
|
||||
|
||||
#[error("Cryptography Error, {0}")]
|
||||
Crypto(#[from] CryptoError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CSParseError {
|
||||
#[error("No type detected, missing '.' separator")]
|
||||
NoType,
|
||||
#[error("Invalid type, got {enc_type} with {parts} parts")]
|
||||
InvalidType { enc_type: String, parts: usize },
|
||||
#[error("Error decoding base64: {0}")]
|
||||
InvalidBase64(#[from] base64::DecodeError),
|
||||
#[error("Invalid base64 length: expected {expected}, got {got}")]
|
||||
InvalidBase64Length { expected: usize, got: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CryptoError {
|
||||
#[error("Error while decrypting cipher string")]
|
||||
KeyDecrypt,
|
||||
}
|
||||
|
||||
// Ensure that the error messages implement Send and Sync
|
||||
#[cfg(test)]
|
||||
const _: () = {
|
||||
fn assert_send<T: Send>() {}
|
||||
fn assert_sync<T: Sync>() {}
|
||||
fn assert_all() {
|
||||
assert_send::<Error>();
|
||||
assert_sync::<Error>();
|
||||
}
|
||||
};
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
@ -2,6 +2,8 @@
|
||||
extern crate napi_derive;
|
||||
|
||||
mod biometric;
|
||||
mod crypto;
|
||||
mod error;
|
||||
mod password;
|
||||
|
||||
#[napi]
|
||||
@ -41,18 +43,67 @@ pub mod passwords {
|
||||
|
||||
#[napi]
|
||||
pub mod biometrics {
|
||||
use super::biometric::{Biometric, BiometricTrait};
|
||||
|
||||
// Prompt for biometric confirmation
|
||||
#[napi]
|
||||
pub async fn prompt(
|
||||
hwnd: napi::bindgen_prelude::Buffer,
|
||||
message: String,
|
||||
) -> napi::Result<bool> {
|
||||
super::biometric::prompt(hwnd.into(), message)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::prompt(hwnd.into(), message).map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn available() -> napi::Result<bool> {
|
||||
super::biometric::available().map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::available().map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn set_biometric_secret(
|
||||
service: String,
|
||||
account: String,
|
||||
secret: String,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: String,
|
||||
) -> napi::Result<String> {
|
||||
Biometric::set_biometric_secret(&service, &account, &secret, key_material, &iv_b64)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn get_biometric_secret(
|
||||
service: String,
|
||||
account: String,
|
||||
key_material: Option<KeyMaterial>,
|
||||
) -> napi::Result<String> {
|
||||
let result = Biometric::get_biometric_secret(&service, &account, key_material)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()));
|
||||
result
|
||||
}
|
||||
|
||||
/// Derives key material from biometric data. Returns a string encoded with a
|
||||
/// base64 encoded key and the base64 encoded challenge used to create it
|
||||
/// separated by a `|` character.
|
||||
///
|
||||
/// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated.
|
||||
///
|
||||
/// `format!("<key_base64>|<iv_base64>")`
|
||||
#[napi]
|
||||
pub async fn derive_key_material(iv: Option<String>) -> napi::Result<OsDerivedKey> {
|
||||
Biometric::derive_key_material(iv.as_deref())
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct KeyMaterial {
|
||||
pub os_key_part_b64: String,
|
||||
pub client_key_part_b64: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct OsDerivedKey {
|
||||
pub key_b64: String,
|
||||
pub iv_b64: String,
|
||||
}
|
||||
}
|
||||
|
@ -108,9 +108,12 @@
|
||||
{{ biometricText | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="help-block" *ngIf="this.form.value.biometric">{{
|
||||
additionalBiometricSettingsText | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<div class="checkbox">
|
||||
<div class="checkbox form-group-child">
|
||||
<label for="autoPromptBiometrics">
|
||||
<input
|
||||
id="autoPromptBiometrics"
|
||||
@ -122,6 +125,22 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<div class="checkbox form-group-child">
|
||||
<label for="requirePasswordOnStart">
|
||||
<input
|
||||
id="requirePasswordOnStart"
|
||||
type="checkbox"
|
||||
formControlName="requirePasswordOnStart"
|
||||
(change)="updateRequirePasswordOnStart()"
|
||||
/>
|
||||
{{ "requirePasswordOnStart" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="help-block form-group-child" *ngIf="isWindows">{{
|
||||
"recommendedForSecurity" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="checkbox">
|
||||
<label for="approveLoginRequests">
|
||||
|
@ -9,15 +9,15 @@ import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { DeviceType, ThemeType, StorageLocation } from "@bitwarden/common/enums";
|
||||
import { DeviceType, ThemeType, StorageLocation, KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
import { flagEnabled } from "../../flags";
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { isWindowsStore } from "../../utils";
|
||||
import { SetPinComponent } from "../components/set-pin.component";
|
||||
|
||||
@ -37,10 +37,12 @@ export class SettingsComponent implements OnInit {
|
||||
clearClipboardOptions: any[];
|
||||
supportsBiometric: boolean;
|
||||
biometricText: string;
|
||||
additionalBiometricSettingsText: string;
|
||||
autoPromptBiometricsText: string;
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
isWindows: boolean;
|
||||
|
||||
enableTrayText: string;
|
||||
enableTrayDescText: string;
|
||||
@ -70,6 +72,7 @@ export class SettingsComponent implements OnInit {
|
||||
pin: [null as boolean | null],
|
||||
biometric: false,
|
||||
autoPromptBiometrics: false,
|
||||
requirePasswordOnStart: false,
|
||||
approveLoginRequests: false,
|
||||
// Account Preferences
|
||||
clearClipboard: [null as number | null],
|
||||
@ -100,7 +103,7 @@ export class SettingsComponent implements OnInit {
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private messagingService: MessagingService,
|
||||
private cryptoService: CryptoService,
|
||||
private modalService: ModalService,
|
||||
@ -182,6 +185,8 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop;
|
||||
|
||||
if ((await this.stateService.getUserId()) == null) {
|
||||
return;
|
||||
}
|
||||
@ -216,7 +221,9 @@ export class SettingsComponent implements OnInit {
|
||||
vaultTimeoutAction: await this.vaultTimeoutSettingsService.getVaultTimeoutAction(),
|
||||
pin: pinSet[0] || pinSet[1],
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
autoPromptBiometrics: !(await this.stateService.getNoAutoPromptBiometrics()),
|
||||
autoPromptBiometrics: !(await this.stateService.getDisableAutoBiometricsPrompt()),
|
||||
requirePasswordOnStart:
|
||||
(await this.stateService.getBiometricRequirePasswordOnStart()) ?? false,
|
||||
approveLoginRequests: (await this.stateService.getApproveLoginRequests()) ?? false,
|
||||
clearClipboard: await this.stateService.getClearClipboard(),
|
||||
minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(),
|
||||
@ -246,6 +253,10 @@ export class SettingsComponent implements OnInit {
|
||||
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
this.supportsBiometric = await this.platformUtilsService.supportsBiometric();
|
||||
this.biometricText = await this.stateService.getBiometricText();
|
||||
this.additionalBiometricSettingsText =
|
||||
this.biometricText === "unlockWithTouchId"
|
||||
? "additionalTouchIdSettings"
|
||||
: "additionalWindowsHelloSettings";
|
||||
this.autoPromptBiometricsText = await this.stateService.getNoAutoPromptBiometricsText();
|
||||
this.previousVaultTimeout = this.form.value.vaultTimeout;
|
||||
|
||||
@ -379,26 +390,52 @@ export class SettingsComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await this.platformUtilsService.authenticateBiometric();
|
||||
|
||||
if (!authResult) {
|
||||
this.form.controls.biometric.setValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.controls.biometric.setValue(true);
|
||||
await this.stateService.setBiometricUnlock(true);
|
||||
if (this.isWindows) {
|
||||
// Recommended settings for Windows Hello
|
||||
this.form.controls.requirePasswordOnStart.setValue(true);
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(true);
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
}
|
||||
await this.cryptoService.toggleKey();
|
||||
|
||||
// Validate the key is stored in case biometrics fail.
|
||||
const biometricSet = await this.cryptoService.hasKeyStored(KeySuffixOptions.Biometric);
|
||||
this.form.controls.biometric.setValue(biometricSet);
|
||||
if (!biometricSet) {
|
||||
await this.stateService.setBiometricUnlock(null);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutoPromptBiometrics() {
|
||||
if (this.form.value.autoPromptBiometrics) {
|
||||
await this.stateService.setNoAutoPromptBiometrics(null);
|
||||
// require password on start must be disabled if auto prompt biometrics is enabled
|
||||
this.form.controls.requirePasswordOnStart.setValue(false);
|
||||
await this.updateRequirePasswordOnStart();
|
||||
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(null);
|
||||
} else {
|
||||
await this.stateService.setNoAutoPromptBiometrics(true);
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRequirePasswordOnStart() {
|
||||
if (this.form.value.requirePasswordOnStart) {
|
||||
// auto prompt biometrics must be disabled if require password on start is enabled
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.updateAutoPromptBiometrics();
|
||||
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(true);
|
||||
} else {
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(false);
|
||||
await this.stateService.setBiometricEncryptionClientKeyHalf(null);
|
||||
}
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
await this.cryptoService.toggleKey();
|
||||
}
|
||||
|
||||
async saveFavicons() {
|
||||
await this.stateService.setDisableFavicon(!this.form.value.enableFavicons);
|
||||
await this.stateService.setDisableFavicon(!this.form.value.enableFavicons, {
|
||||
|
@ -48,11 +48,12 @@ import { ElectronPlatformUtilsService } from "../../services/electron-platform-u
|
||||
import { ElectronRendererMessagingService } from "../../services/electron-renderer-messaging.service";
|
||||
import { ElectronRendererSecureStorageService } from "../../services/electron-renderer-secure-storage.service";
|
||||
import { ElectronRendererStorageService } from "../../services/electron-renderer-storage.service";
|
||||
import { ElectronStateService } from "../../services/electron-state.service";
|
||||
import { ElectronStateService as ElectronStateServiceAbstraction } from "../../services/electron-state.service.abstraction";
|
||||
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
|
||||
import { I18nService } from "../../services/i18n.service";
|
||||
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
import { StateService } from "../../services/state.service";
|
||||
import { PasswordRepromptService } from "../../vault/services/password-reprompt.service";
|
||||
import { SearchBarService } from "../layout/search/search-bar.service";
|
||||
|
||||
@ -112,17 +113,6 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
{ provide: AbstractStorageService, useClass: ElectronRendererStorageService },
|
||||
{ provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService },
|
||||
{ provide: MEMORY_STORAGE, useClass: MemoryStorageService },
|
||||
{
|
||||
provide: CryptoServiceAbstraction,
|
||||
useClass: ElectronCryptoService,
|
||||
deps: [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
LogServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: SystemServiceAbstraction,
|
||||
useClass: SystemService,
|
||||
@ -136,7 +126,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
{ provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService },
|
||||
{
|
||||
provide: StateServiceAbstraction,
|
||||
useClass: StateService,
|
||||
useClass: ElectronStateService,
|
||||
deps: [
|
||||
AbstractStorageService,
|
||||
SECURE_STORAGE,
|
||||
@ -147,6 +137,10 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
STATE_SERVICE_USE_CACHE,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: ElectronStateServiceAbstraction,
|
||||
useExisting: StateServiceAbstraction,
|
||||
},
|
||||
{
|
||||
provide: FileDownloadService,
|
||||
useClass: DesktopFileDownloadService,
|
||||
@ -182,6 +176,17 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
useClass: LoginService,
|
||||
deps: [StateServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: CryptoServiceAbstraction,
|
||||
useClass: ElectronCryptoService,
|
||||
deps: [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
LogService,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
|
@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons with-rows">
|
||||
<div class="buttons-row" *ngIf="supportsBiometric && biometricLock">
|
||||
<div class="buttons-row" *ngIf="supportsBiometric && biometricLock && biometricReady">
|
||||
<button
|
||||
type="button"
|
||||
class="btn block"
|
||||
|
@ -13,14 +13,17 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.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 { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { DeviceType, KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
|
||||
import { ElectronStateService } from "../services/electron-state.service.abstraction";
|
||||
import { BiometricStorageAction, BiometricMessage } from "../types/biometric-message";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
@Component({
|
||||
@ -29,6 +32,7 @@ const BroadcasterSubscriptionId = "LockComponent";
|
||||
})
|
||||
export class LockComponent extends BaseLockComponent {
|
||||
private deferFocus: boolean = null;
|
||||
protected biometricReady = false;
|
||||
protected oldOs = false;
|
||||
protected deprecated = false;
|
||||
|
||||
@ -41,7 +45,7 @@ export class LockComponent extends BaseLockComponent {
|
||||
vaultTimeoutService: VaultTimeoutService,
|
||||
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
environmentService: EnvironmentService,
|
||||
stateService: StateService,
|
||||
protected override stateService: ElectronStateService,
|
||||
apiService: ApiService,
|
||||
private route: ActivatedRoute,
|
||||
private broadcasterService: BroadcasterService,
|
||||
@ -88,7 +92,10 @@ export class LockComponent extends BaseLockComponent {
|
||||
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
const autoPromptBiometric = !(await this.stateService.getNoAutoPromptBiometrics());
|
||||
const autoPromptBiometric = !(await this.stateService.getDisableAutoBiometricsPrompt());
|
||||
this.biometricReady = await this.canUseBiometric();
|
||||
|
||||
await this.displayBiometricUpdateWarning();
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
@ -135,7 +142,44 @@ export class LockComponent extends BaseLockComponent {
|
||||
this.showPassword = false;
|
||||
}
|
||||
|
||||
private async canUseBiometric() {
|
||||
const userId = await this.stateService.getUserId();
|
||||
const val = await ipcRenderer.invoke("biometric", {
|
||||
action: BiometricStorageAction.EnabledForUser,
|
||||
key: `${userId}_masterkey_biometric`,
|
||||
keySuffix: KeySuffixOptions.Biometric,
|
||||
userId: userId,
|
||||
} as BiometricMessage);
|
||||
return val != null ? (JSON.parse(val) as boolean) : null;
|
||||
}
|
||||
|
||||
private focusInput() {
|
||||
document.getElementById(this.pinLock ? "pin" : "masterPassword").focus();
|
||||
}
|
||||
|
||||
private async displayBiometricUpdateWarning(): Promise<void> {
|
||||
if (await this.stateService.getDismissedBiometricRequirePasswordOnStart()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.platformUtilsService.getDevice() !== DeviceType.WindowsDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.stateService.getBiometricUnlock()) {
|
||||
const response = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("windowsBiometricUpdateWarning"),
|
||||
this.i18nService.t("windowsBiometricUpdateWarningTitle"),
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no")
|
||||
);
|
||||
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(response);
|
||||
if (response) {
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
}
|
||||
this.supportsBiometric = await this.canUseBiometric();
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1392,20 +1392,32 @@
|
||||
"unlockWithWindowsHello": {
|
||||
"message": "Unlock with Windows Hello"
|
||||
},
|
||||
"additionalWindowsHelloSettings": {
|
||||
"message": "Additional Windows Hello settings"
|
||||
},
|
||||
"windowsHelloConsentMessage": {
|
||||
"message": "Verify for Bitwarden."
|
||||
},
|
||||
"unlockWithTouchId": {
|
||||
"message": "Unlock with Touch ID"
|
||||
},
|
||||
"additionalTouchIdSettings": {
|
||||
"message": "Additional Touch ID settings"
|
||||
},
|
||||
"touchIdConsentMessage": {
|
||||
"message": "unlock your vault"
|
||||
},
|
||||
"autoPromptWindowsHello": {
|
||||
"message": "Ask for Windows Hello on launch"
|
||||
"message": "Ask for Windows Hello on app start"
|
||||
},
|
||||
"autoPromptTouchId": {
|
||||
"message": "Ask for Touch ID on launch"
|
||||
"message": "Ask for Touch ID on app start"
|
||||
},
|
||||
"requirePasswordOnStart": {
|
||||
"message": "Require password or PIN on app start"
|
||||
},
|
||||
"recommendedForSecurity": {
|
||||
"message": "Recommended for security."
|
||||
},
|
||||
"lockWithMasterPassOnRestart": {
|
||||
"message": "Lock with master password on restart"
|
||||
@ -2234,6 +2246,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowsBiometricUpdateWarning": {
|
||||
"message": "Bitwarden recommends updating your biometric settings to require your master password (or PIN) on the first unlock. Would you like to update your settings now?"
|
||||
},
|
||||
"windowsBiometricUpdateWarningTitle": {
|
||||
"message": "Recommended Settings Update"
|
||||
},
|
||||
"windows8SoonDeprecated": {
|
||||
"message": "The operating system you are using will no longer be supported after the 2023.5.0 release. Upgrade to a supported operating system. Continuing without updating your operating system may result in unexpected behavior or security risks.",
|
||||
"description": "Windows 8, 8.1 and Server 2012 R2 are no longer supported by Electron & Chromium. Show a notice on the login and lock screen while 2023.4.0 is the active version."
|
||||
|
@ -5,7 +5,6 @@ import { app } from "electron";
|
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
||||
import { StateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { BiometricsService, BiometricsServiceAbstraction } from "./main/biometric/index";
|
||||
import { DesktopCredentialStorageListener } from "./main/desktop-credential-storage-listener";
|
||||
@ -19,6 +18,7 @@ import { WindowMain } from "./main/window.main";
|
||||
import { Account } from "./models/account";
|
||||
import { ElectronLogService } from "./services/electron-log.service";
|
||||
import { ElectronMainMessagingService } from "./services/electron-main-messaging.service";
|
||||
import { ElectronStateService } from "./services/electron-state.service";
|
||||
import { ElectronStorageService } from "./services/electron-storage.service";
|
||||
import { I18nService } from "./services/i18n.service";
|
||||
|
||||
@ -28,7 +28,7 @@ export class Main {
|
||||
storageService: ElectronStorageService;
|
||||
memoryStorageService: MemoryStorageService;
|
||||
messagingService: ElectronMainMessagingService;
|
||||
stateService: StateService;
|
||||
stateService: ElectronStateService;
|
||||
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
||||
|
||||
windowMain: WindowMain;
|
||||
@ -85,7 +85,7 @@ export class Main {
|
||||
// TODO: this state service will have access to on disk storage, but not in memory storage.
|
||||
// If we could get this to work using the stateService singleton that the rest of the app uses we could save
|
||||
// ourselves from some hacks, like having to manually update the app menu vs. the menu subscribing to events.
|
||||
this.stateService = new StateService(
|
||||
this.stateService = new ElectronStateService(
|
||||
this.storageService,
|
||||
null,
|
||||
this.memoryStorageService,
|
||||
@ -128,7 +128,8 @@ export class Main {
|
||||
|
||||
this.desktopCredentialStorageListener = new DesktopCredentialStorageListener(
|
||||
"Bitwarden",
|
||||
this.biometricsService
|
||||
this.biometricsService,
|
||||
this.logService
|
||||
);
|
||||
|
||||
this.nativeMessagingMain = new NativeMessagingMain(
|
||||
|
@ -1,25 +1,21 @@
|
||||
import { ipcMain, systemPreferences } from "electron";
|
||||
import { systemPreferences } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
export default class BiometricDarwinMain implements BiometricsServiceAbstraction {
|
||||
export default class BiometricDarwinMain implements OsBiometricService {
|
||||
constructor(private i18nservice: I18nService, private stateService: StateService) {}
|
||||
|
||||
async init() {
|
||||
await this.stateService.setEnableBiometric(await this.supportsBiometric());
|
||||
await this.stateService.setBiometricText("unlockWithTouchId");
|
||||
await this.stateService.setNoAutoPromptBiometricsText("autoPromptTouchId");
|
||||
|
||||
ipcMain.handle("biometric", async () => {
|
||||
return await this.authenticateBiometric();
|
||||
});
|
||||
}
|
||||
|
||||
supportsBiometric(): Promise<boolean> {
|
||||
return Promise.resolve(systemPreferences.canPromptTouchID());
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return systemPreferences.canPromptTouchID();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
@ -30,4 +26,35 @@ export default class BiometricDarwinMain implements BiometricsServiceAbstraction
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getBiometricKey(service: string, key: string): Promise<string | null> {
|
||||
const success = await this.authenticateBiometric();
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Biometric authentication failed");
|
||||
}
|
||||
|
||||
return await passwords.getPassword(service, key);
|
||||
}
|
||||
|
||||
async setBiometricKey(service: string, key: string, value: string): Promise<void> {
|
||||
if (await this.valueUpToDate(service, key, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await passwords.setPassword(service, key, value);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {
|
||||
return await passwords.deletePassword(service, key);
|
||||
}
|
||||
|
||||
private async valueUpToDate(service: string, key: string, value: string): Promise<boolean> {
|
||||
try {
|
||||
const existing = await passwords.getPassword(service, key);
|
||||
return existing === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,48 +1,224 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { biometrics } from "@bitwarden/desktop-native";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
const KEY_WITNESS_SUFFIX = "_witness";
|
||||
const WITNESS_VALUE = "known key";
|
||||
|
||||
export default class BiometricWindowsMain implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
private _osKeyHalf: string | null = null;
|
||||
|
||||
export default class BiometricWindowsMain implements BiometricsServiceAbstraction {
|
||||
constructor(
|
||||
private i18nservice: I18nService,
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
let supportsBiometric = false;
|
||||
try {
|
||||
supportsBiometric = await this.supportsBiometric();
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
await this.stateService.setEnableBiometric(supportsBiometric);
|
||||
await this.stateService.setBiometricText("unlockWithWindowsHello");
|
||||
await this.stateService.setNoAutoPromptBiometricsText("autoPromptWindowsHello");
|
||||
|
||||
ipcMain.handle("biometric", async () => {
|
||||
return await this.authenticateBiometric();
|
||||
});
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
try {
|
||||
return await biometrics.available();
|
||||
} catch {
|
||||
return false;
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return await biometrics.available();
|
||||
}
|
||||
|
||||
async getBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyHalfB64: string
|
||||
): Promise<string | null> {
|
||||
const value = await passwords.getPassword(service, storageKey);
|
||||
|
||||
if (value == null || value == "") {
|
||||
return null;
|
||||
} else if (!EncString.isSerializedEncString(value)) {
|
||||
// Update to format encrypted with client key half
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
});
|
||||
|
||||
await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
return value;
|
||||
} else {
|
||||
const encValue = new EncString(value);
|
||||
this.setIv(encValue.iv);
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
});
|
||||
return await biometrics.getBiometricSecret(service, storageKey, storageDetails.key_material);
|
||||
}
|
||||
}
|
||||
|
||||
async setBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
value: string,
|
||||
clientKeyPartB64: string | undefined
|
||||
): Promise<void> {
|
||||
const parsedValue = SymmetricCryptoKey.fromString(value);
|
||||
if (await this.valueUpToDate({ value: parsedValue, clientKeyPartB64, service, storageKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
|
||||
const storedValue = await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
const parsedStoredValue = new EncString(storedValue);
|
||||
await this.storeValueWitness(
|
||||
parsedValue,
|
||||
parsedStoredValue,
|
||||
service,
|
||||
storageKey,
|
||||
clientKeyPartB64
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {
|
||||
await passwords.deletePassword(service, key);
|
||||
await passwords.deletePassword(service, key + KEY_WITNESS_SUFFIX);
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const hwnd = this.windowMain.win.getNativeWindowHandle();
|
||||
return await biometrics.prompt(hwnd, this.i18nservice.t("windowsHelloConsentMessage"));
|
||||
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
|
||||
}
|
||||
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
// Prompts Windows Hello
|
||||
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
|
||||
this._osKeyHalf = keyMaterial.keyB64;
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
return {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv: string) {
|
||||
this._iv = iv;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date.
|
||||
*
|
||||
* @param unencryptedValue The key to store
|
||||
* @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key.
|
||||
* @param service The service to store the witness key under
|
||||
* @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
|
||||
* @returns
|
||||
*/
|
||||
private async storeValueWitness(
|
||||
unencryptedValue: SymmetricCryptoKey,
|
||||
encryptedValue: EncString,
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyPartB64: string
|
||||
) {
|
||||
if (encryptedValue.iv == null || encryptedValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageDetails = {
|
||||
keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64),
|
||||
ivB64: encryptedValue.iv,
|
||||
};
|
||||
await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey + KEY_WITNESS_SUFFIX,
|
||||
WITNESS_VALUE,
|
||||
storageDetails.keyMaterial,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
|
||||
* @param value The value being validated
|
||||
* @param service The service the value is stored under
|
||||
* @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
|
||||
* @returns Boolean indicating if the value is up to date.
|
||||
*/
|
||||
// Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
|
||||
private async valueUpToDate({
|
||||
value,
|
||||
clientKeyPartB64,
|
||||
service,
|
||||
storageKey,
|
||||
}: {
|
||||
value: SymmetricCryptoKey;
|
||||
clientKeyPartB64: string;
|
||||
service: string;
|
||||
storageKey: string;
|
||||
}): Promise<boolean> {
|
||||
const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64);
|
||||
if (witnessKeyMaterial == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let witness = null;
|
||||
try {
|
||||
witness = await biometrics.getBiometricSecret(
|
||||
service,
|
||||
storageKey + KEY_WITNESS_SUFFIX,
|
||||
witnessKeyMaterial
|
||||
);
|
||||
} catch {
|
||||
this.logService.debug("Error retrieving witness key, assuming value is not up to date.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (witness === WITNESS_VALUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Derives a witness key from a symmetric key being stored for biometric protection */
|
||||
private witnessKeyMaterial(
|
||||
symmetricKey: SymmetricCryptoKey,
|
||||
clientKeyPartB64: string
|
||||
): biometrics.KeyMaterial {
|
||||
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
|
||||
return {
|
||||
osKeyPartB64: key,
|
||||
clientKeyPartB64,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,44 @@
|
||||
export abstract class BiometricsServiceAbstraction {
|
||||
init: () => Promise<void>;
|
||||
supportsBiometric: () => Promise<boolean>;
|
||||
osSupportsBiometric: () => Promise<boolean>;
|
||||
canAuthBiometric: ({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}) => Promise<boolean>;
|
||||
authenticateBiometric: () => Promise<boolean>;
|
||||
getBiometricKey: (service: string, key: string) => Promise<string | null>;
|
||||
setBiometricKey: (service: string, key: string, value: string) => Promise<void>;
|
||||
setEncryptionKeyHalf: ({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => void;
|
||||
deleteBiometricKey: (service: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface OsBiometricService {
|
||||
init: () => Promise<void>;
|
||||
osSupportsBiometric: () => Promise<boolean>;
|
||||
authenticateBiometric: () => Promise<boolean>;
|
||||
getBiometricKey: (
|
||||
service: string,
|
||||
key: string,
|
||||
clientKeyHalfB64: string | undefined
|
||||
) => Promise<string | null>;
|
||||
setBiometricKey: (
|
||||
service: string,
|
||||
key: string,
|
||||
value: string,
|
||||
clientKeyHalfB64: string | undefined
|
||||
) => Promise<void>;
|
||||
deleteBiometricKey: (service: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import BiometricDarwinMain from "./biometric.darwin.main";
|
||||
import BiometricWindowsMain from "./biometric.windows.main";
|
||||
import { BiometricsService } from "./biometrics.service";
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
jest.mock("@bitwarden/desktop-native", () => {
|
||||
return {
|
||||
@ -22,11 +22,11 @@ jest.mock("@bitwarden/desktop-native", () => {
|
||||
describe("biometrics tests", function () {
|
||||
const i18nService = mock<I18nService>();
|
||||
const windowMain = mock<WindowMain>();
|
||||
const stateService = mock<StateService>();
|
||||
const stateService = mock<ElectronStateService>();
|
||||
const logService = mock<LogService>();
|
||||
const messagingService = mock<MessagingService>();
|
||||
|
||||
it("Should call the platformspecific methods", () => {
|
||||
it("Should call the platformspecific methods", async () => {
|
||||
const sut = new BiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
@ -36,13 +36,14 @@ describe("biometrics tests", function () {
|
||||
process.platform
|
||||
);
|
||||
|
||||
const mockService = mock<BiometricsServiceAbstraction>();
|
||||
const mockService = mock<OsBiometricService>();
|
||||
(sut as any).platformSpecificService = mockService;
|
||||
sut.init();
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
expect(mockService.init).toBeCalled();
|
||||
|
||||
sut.supportsBiometric();
|
||||
expect(mockService.supportsBiometric).toBeCalled();
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
expect(mockService.osSupportsBiometric).toBeCalled();
|
||||
|
||||
sut.authenticateBiometric();
|
||||
expect(mockService.authenticateBiometric).toBeCalled();
|
||||
@ -78,4 +79,50 @@ describe("biometrics tests", function () {
|
||||
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can auth biometric", () => {
|
||||
let sut: BiometricsService;
|
||||
let innerService: MockProxy<OsBiometricService>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
stateService,
|
||||
logService,
|
||||
messagingService,
|
||||
process.platform
|
||||
);
|
||||
|
||||
innerService = mock();
|
||||
(sut as any).platformSpecificService = innerService;
|
||||
sut.init();
|
||||
});
|
||||
|
||||
it("should return false if client key half is required and not provided", async () => {
|
||||
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should call osSupportsBiometric if client key half is provided", async () => {
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
expect(innerService.init).toBeCalled();
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
|
||||
it("should call osSupportBiometric if client key half is not required", async () => {
|
||||
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(false);
|
||||
innerService.osSupportsBiometric.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,19 +1,20 @@
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { BiometricsServiceAbstraction, OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
export class BiometricsService implements BiometricsServiceAbstraction {
|
||||
private platformSpecificService: BiometricsServiceAbstraction;
|
||||
private platformSpecificService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform
|
||||
@ -50,16 +51,121 @@ export class BiometricsService implements BiometricsServiceAbstraction {
|
||||
return await this.platformSpecificService.init();
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await this.platformSpecificService.supportsBiometric();
|
||||
async osSupportsBiometric() {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart({
|
||||
userId,
|
||||
});
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
return clientKeyHalfSatisfied && (await this.osSupportsBiometric());
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
let result = false;
|
||||
this.interruptProcessReload(
|
||||
() => {
|
||||
return this.platformSpecificService.authenticateBiometric();
|
||||
},
|
||||
(response) => {
|
||||
result = response;
|
||||
return !response;
|
||||
}
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getBiometricKey(service: string, storageKey: string): Promise<string | null> {
|
||||
return await this.interruptProcessReload(async () => {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.getBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
this.getClientKeyHalf(service, storageKey)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async setBiometricKey(service: string, storageKey: string, value: string): Promise<void> {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.setBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
this.getClientKeyHalf(service, storageKey)
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers the client-side encryption key half for the OS stored Biometric key. The other half is protected by the OS.*/
|
||||
async setEncryptionKeyHalf({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
if (value == null) {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, key));
|
||||
} else {
|
||||
this.clientKeyHalves.set(this.clientKeyHalfKey(service, key), value);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, storageKey: string): Promise<void> {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, storageKey));
|
||||
return await this.platformSpecificService.deleteBiometricKey(service, storageKey);
|
||||
}
|
||||
|
||||
private async interruptProcessReload<T>(
|
||||
callback: () => Promise<T>,
|
||||
restartReloadCallback: (arg: T) => boolean = () => false
|
||||
): Promise<T> {
|
||||
this.messagingService.send("cancelProcessReload");
|
||||
const response = await this.platformSpecificService.authenticateBiometric();
|
||||
if (!response) {
|
||||
let restartReload = false;
|
||||
let response: T;
|
||||
try {
|
||||
response = await callback();
|
||||
restartReload ||= restartReloadCallback(response);
|
||||
} catch {
|
||||
restartReload = true;
|
||||
}
|
||||
|
||||
if (restartReload) {
|
||||
this.messagingService.send("startProcessReload");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private clientKeyHalfKey(service: string, key: string): string {
|
||||
return `${service}:${key}`;
|
||||
}
|
||||
|
||||
private getClientKeyHalf(service: string, key: string): string | undefined {
|
||||
return this.clientKeyHalves.get(this.clientKeyHalfKey(service, key)) ?? undefined;
|
||||
}
|
||||
|
||||
private async enforceClientKeyHalf(service: string, storageKey: string): Promise<void> {
|
||||
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart();
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, storageKey);
|
||||
|
||||
if (requireClientKeyHalf && !clientKeyHalfB64) {
|
||||
throw new Error("Biometric key requirements not met. No client key half provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,20 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
|
||||
import { passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { BiometricMessage, BiometricStorageAction } from "../types/biometric-message";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometric/index";
|
||||
|
||||
const AuthRequiredSuffix = "_biometric";
|
||||
const AuthenticatedActions = ["getPassword"];
|
||||
|
||||
export class DesktopCredentialStorageListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: BiometricsServiceAbstraction
|
||||
private biometricService: BiometricsServiceAbstraction,
|
||||
private logService: ConsoleLogService
|
||||
) {}
|
||||
|
||||
init() {
|
||||
@ -22,46 +26,107 @@ export class DesktopCredentialStorageListener {
|
||||
serviceName += message.keySuffix;
|
||||
}
|
||||
|
||||
const authenticationRequired =
|
||||
AuthenticatedActions.includes(message.action) && AuthRequiredSuffix === message.keySuffix;
|
||||
const authenticated = !authenticationRequired || (await this.authenticateBiometric());
|
||||
|
||||
let val: string | boolean = null;
|
||||
if (authenticated && message.action && message.key) {
|
||||
if (message.action && message.key) {
|
||||
if (message.action === "getPassword") {
|
||||
val = await this.getPassword(serviceName, message.key);
|
||||
val = await this.getPassword(serviceName, message.key, message.keySuffix);
|
||||
} else if (message.action === "hasPassword") {
|
||||
const result = await this.getPassword(serviceName, message.key);
|
||||
const result = await passwords.getPassword(serviceName, message.key);
|
||||
val = result != null;
|
||||
} else if (message.action === "setPassword" && message.value) {
|
||||
await passwords.setPassword(serviceName, message.key, message.value);
|
||||
await this.setPassword(serviceName, message.key, message.value, message.keySuffix);
|
||||
} else if (message.action === "deletePassword") {
|
||||
await passwords.deletePassword(serviceName, message.key);
|
||||
await this.deletePassword(serviceName, message.key, message.keySuffix);
|
||||
}
|
||||
}
|
||||
return val;
|
||||
} catch {
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (
|
||||
e.message === "Password not found." ||
|
||||
e.message === "The specified item could not be found in the keychain."
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
|
||||
try {
|
||||
let serviceName = this.serviceName;
|
||||
message.keySuffix = "_" + (message.keySuffix ?? "");
|
||||
if (message.keySuffix !== "_") {
|
||||
serviceName += message.keySuffix;
|
||||
}
|
||||
|
||||
let val: string | boolean = null;
|
||||
|
||||
if (!message.action) {
|
||||
return val;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case BiometricStorageAction.EnabledForUser:
|
||||
if (!message.key || !message.userId) {
|
||||
break;
|
||||
}
|
||||
val = await this.biometricService.canAuthBiometric({
|
||||
service: serviceName,
|
||||
key: message.key,
|
||||
userId: message.userId,
|
||||
});
|
||||
break;
|
||||
case BiometricStorageAction.OsSupported:
|
||||
val = await this.biometricService.osSupportsBiometric();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return val;
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
|
||||
private async getPassword(serviceName: string, key: string) {
|
||||
let val = await passwords.getPassword(serviceName, key);
|
||||
private async getPassword(serviceName: string, key: string, keySuffix: string) {
|
||||
let val: string;
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
|
||||
} else {
|
||||
val = await passwords.getPassword(serviceName, key);
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(val);
|
||||
} catch (e) {
|
||||
val = await passwords.getPasswordKeytar(serviceName, key);
|
||||
await passwords.setPassword(serviceName, key, val);
|
||||
throw new Error("Password in bad format" + e + val);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
private async authenticateBiometric(): Promise<boolean> {
|
||||
if (this.biometricService) {
|
||||
return await this.biometricService.authenticateBiometric();
|
||||
private async setPassword(serviceName: string, key: string, value: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
const valueObj = JSON.parse(value) as BiometricKey;
|
||||
await this.biometricService.setEncryptionKeyHalf({
|
||||
service: serviceName,
|
||||
key,
|
||||
value: valueObj?.clientEncKeyHalf,
|
||||
});
|
||||
// Value is usually a JSON string, but we need to pass the key half as well, so we re-stringify key here.
|
||||
await this.biometricService.setBiometricKey(serviceName, key, JSON.stringify(valueObj?.key));
|
||||
} else {
|
||||
await passwords.setPassword(serviceName, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async deletePassword(serviceName: string, key: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
await this.biometricService.deleteBiometricKey(serviceName, key);
|
||||
} else {
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,25 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
Account as BaseAccount,
|
||||
AccountSettings as BaseAccountSettings,
|
||||
AccountKeys as BaseAccountKeys,
|
||||
} from "@bitwarden/common/models/domain/account";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class AccountSettings extends BaseAccountSettings {
|
||||
vaultTimeout = -1; // On Restart
|
||||
requirePasswordOnStart?: boolean;
|
||||
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
|
||||
}
|
||||
|
||||
export class AccountKeys extends BaseAccountKeys {
|
||||
biometricEncryptionClientKeyHalf?: Jsonify<EncString>;
|
||||
}
|
||||
|
||||
export class Account extends BaseAccount {
|
||||
settings?: AccountSettings = new AccountSettings();
|
||||
keys?: AccountKeys = new AccountKeys();
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
super(init);
|
||||
|
@ -331,6 +331,10 @@ form,
|
||||
}
|
||||
}
|
||||
|
||||
.form-group-child {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
@ -2,72 +2,65 @@ import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunc
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "@bitwarden/common/services/crypto.service";
|
||||
import { CsprngString } from "@bitwarden/common/types/csprng";
|
||||
|
||||
import { ElectronStateService } from "./electron-state.service.abstraction";
|
||||
|
||||
export class ElectronCryptoService extends CryptoService {
|
||||
constructor(
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilService: PlatformUtilsService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
logService: LogService,
|
||||
stateService: StateService
|
||||
protected override stateService: ElectronStateService
|
||||
) {
|
||||
super(cryptoFunctionService, encryptService, platformUtilService, logService, stateService);
|
||||
super(cryptoFunctionService, encryptService, platformUtilsService, logService, stateService);
|
||||
}
|
||||
|
||||
async hasKeyStored(keySuffix: KeySuffixOptions): Promise<boolean> {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.hasKeyStored(keySuffix);
|
||||
}
|
||||
protected override async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
await super.storeKey(key, userId);
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId);
|
||||
|
||||
if (storeBiometricKey) {
|
||||
await this.storeBiometricKey(key, userId);
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Auto);
|
||||
}
|
||||
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key.keyB64, { userId: userId });
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Biometric);
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
protected async retrieveKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.retrieveKeyFromStorage(keySuffix, userId);
|
||||
protected async storeBiometricKey(key: SymmetricCryptoKey, userId?: string): Promise<void> {
|
||||
let clientEncKeyHalf: CsprngString = null;
|
||||
if (await this.stateService.getBiometricRequirePasswordOnStart({ userId })) {
|
||||
clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userId);
|
||||
}
|
||||
await this.stateService.setCryptoMasterKeyBiometric(
|
||||
{ key: key.keyB64, clientEncKeyHalf },
|
||||
{ userId: userId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 4 Jun 2021 This is temporary upgrade method to move from a single shared stored key to
|
||||
* multiple, unique stored keys for each use, e.g. never logout vs. biometric authentication.
|
||||
*/
|
||||
private async upgradeSecurelyStoredKey() {
|
||||
// attempt key upgrade, but if we fail just delete it. Keys will be stored property upon unlock anyway.
|
||||
const key = await this.stateService.getCryptoMasterKeyB64();
|
||||
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
private async getBiometricEncryptionClientKeyHalf(userId?: string): Promise<CsprngString | null> {
|
||||
try {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key);
|
||||
let biometricKey = await this.stateService
|
||||
.getBiometricEncryptionClientKeyHalf({ userId })
|
||||
.then((result) => result?.decrypt(null /* user encrypted */))
|
||||
.then((result) => result as CsprngString);
|
||||
const userKey = await this.getKeyForUserEncryption();
|
||||
if (biometricKey == null && userKey != null) {
|
||||
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
|
||||
await this.stateService.setBiometricEncryptionClientKeyHalf(encKey);
|
||||
}
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Encountered error while upgrading obsolete Bitwarden secure storage item:`
|
||||
);
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
await this.stateService.setCryptoMasterKeyB64(null);
|
||||
return biometricKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { BiometricMessage, BiometricStorageAction } from "../types/biometric-message";
|
||||
import { isDev, isMacAppStore } from "../utils";
|
||||
|
||||
export class ElectronPlatformUtilsService implements PlatformUtilsService {
|
||||
@ -169,9 +170,15 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await this.stateService.getEnableBiometric();
|
||||
return await ipcRenderer.invoke("biometric", {
|
||||
action: BiometricStorageAction.OsSupported,
|
||||
} as BiometricMessage);
|
||||
}
|
||||
|
||||
/** This method is used to authenticate the user presence _only_.
|
||||
* It should not be used in the process to retrieve
|
||||
* biometric keys, which has a separate authentication mechanism.
|
||||
* For biometric keys, invoke "keytar" with a biometric key suffix */
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const val = await ipcRenderer.invoke("biometric", {
|
||||
action: "authenticate",
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
export abstract class ElectronStateService extends StateService<Account> {
|
||||
getBiometricEncryptionClientKeyHalf: (options?: StorageOptions) => Promise<EncString>;
|
||||
setBiometricEncryptionClientKeyHalf: (
|
||||
value: EncString,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDismissedBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDismissedBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<void>;
|
||||
getBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricRequirePasswordOnStart: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
}
|
80
apps/desktop/src/services/electron-state.service.ts
Normal file
80
apps/desktop/src/services/electron-state.service.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
import { ElectronStateService as ElectronStateServiceAbstraction } from "./electron-state.service.abstraction";
|
||||
|
||||
export class ElectronStateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements ElectronStateServiceAbstraction
|
||||
{
|
||||
async addAccount(account: Account) {
|
||||
// Apply desktop overides to default account values
|
||||
account = new Account(account);
|
||||
await super.addAccount(account);
|
||||
}
|
||||
|
||||
async getBiometricEncryptionClientKeyHalf(options?: StorageOptions): Promise<EncString> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
const key = account?.keys?.biometricEncryptionClientKeyHalf;
|
||||
return key == null ? null : new EncString(key);
|
||||
}
|
||||
|
||||
async setBiometricEncryptionClientKeyHalf(
|
||||
value: EncString,
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.keys.biometricEncryptionClientKeyHalf = value?.encryptedString;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<boolean> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
return account?.settings?.requirePasswordOnStart;
|
||||
}
|
||||
|
||||
async setBiometricRequirePasswordOnStart(
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.settings.requirePasswordOnStart = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getDismissedBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<boolean> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
return account?.settings?.dismissedBiometricRequirePasswordOnStartCallout;
|
||||
}
|
||||
|
||||
async setDismissedBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout = true;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
}
|
@ -25,11 +25,11 @@ import { GenerateResponse } from "../models/native-messaging/encrypted-message-r
|
||||
import { SuccessStatusResponse } from "../models/native-messaging/encrypted-message-responses/success-status-response";
|
||||
import { UserStatusErrorResponse } from "../models/native-messaging/encrypted-message-responses/user-status-error-response";
|
||||
|
||||
import { StateService } from "./state.service";
|
||||
import { ElectronStateService } from "./electron-state.service";
|
||||
|
||||
export class EncryptedMessageHandlerService {
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private authService: AuthService,
|
||||
private cipherService: CipherService,
|
||||
private policyService: PolicyService,
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
export class StateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements StateServiceAbstraction
|
||||
{
|
||||
async addAccount(account: Account) {
|
||||
// Apply desktop overides to default account values
|
||||
account = new Account(account);
|
||||
await super.addAccount(account);
|
||||
}
|
||||
}
|
11
apps/desktop/src/types/biometric-message.ts
Normal file
11
apps/desktop/src/types/biometric-message.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export enum BiometricStorageAction {
|
||||
EnabledForUser = "enabled",
|
||||
OsSupported = "osSupported",
|
||||
}
|
||||
|
||||
export type BiometricMessage = {
|
||||
action: BiometricStorageAction;
|
||||
keySuffix?: string;
|
||||
key?: string;
|
||||
userId?: string;
|
||||
};
|
@ -1,3 +1,5 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
describe("Utils Service", () => {
|
||||
@ -343,7 +345,7 @@ describe("Utils Service", () => {
|
||||
it("removes multiple encoded traversals", () => {
|
||||
expect(
|
||||
Utils.normalizePath("api/sends/access/..%2f..%2f..%2fapi%2fsends%2faccess%2fsendkey")
|
||||
).toBe("api/sends/access/sendkey");
|
||||
).toBe(path.normalize("api/sends/access/sendkey"));
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -25,6 +25,16 @@ describe("EncString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("3.data")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("3.data|test")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("3.data");
|
||||
@ -89,6 +99,16 @@ describe("EncString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("0.iv|data")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("0.iv|data|mac")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("0.iv|data");
|
||||
@ -125,6 +145,16 @@ describe("EncString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSerializedEncString", () => {
|
||||
it("is true if valid", () => {
|
||||
expect(EncString.isSerializedEncString("2.iv|data|mac")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false if invalid", () => {
|
||||
expect(EncString.isSerializedEncString("2.iv|data")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("valid", () => {
|
||||
const encString = new EncString("2.iv|data|mac");
|
||||
|
@ -8,6 +8,7 @@ import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation";
|
||||
|
||||
import { CsprngArray } from "../../src/types/csprng";
|
||||
import { makeStaticByteArray } from "../utils";
|
||||
|
||||
describe("EncryptService", () => {
|
||||
@ -37,7 +38,9 @@ describe("EncryptService", () => {
|
||||
|
||||
describe("encrypts data", () => {
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService.randomBytes.calledWith(16).mockResolvedValueOnce(iv.buffer);
|
||||
cryptoFunctionService.randomBytes
|
||||
.calledWith(16)
|
||||
.mockResolvedValueOnce(iv.buffer as CsprngArray);
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData.buffer);
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { StateVersion } from "@bitwarden/common/enums";
|
||||
@ -14,14 +15,14 @@ const userId = "USER_ID";
|
||||
// so that we don't accidentally run all following migrations as well
|
||||
|
||||
describe("State Migration Service", () => {
|
||||
let storageService: SubstituteOf<AbstractStorageService>;
|
||||
let storageService: MockProxy<AbstractStorageService>;
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let stateFactory: SubstituteOf<StateFactory>;
|
||||
|
||||
let stateMigrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = Substitute.for<AbstractStorageService>();
|
||||
storageService = mock();
|
||||
secureStorageService = Substitute.for<AbstractStorageService>();
|
||||
stateFactory = Substitute.for<StateFactory>();
|
||||
|
||||
@ -32,14 +33,18 @@ describe("State Migration Service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("StateVersion 3 to 4 migration", () => {
|
||||
beforeEach(() => {
|
||||
const globalVersion3: Partial<GlobalState> = {
|
||||
stateVersion: StateVersion.Three,
|
||||
};
|
||||
|
||||
storageService.get("global", Arg.any()).resolves(globalVersion3);
|
||||
storageService.get("authenticatedAccounts", Arg.any()).resolves([userId]);
|
||||
storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3);
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
});
|
||||
|
||||
it("clears everBeenUnlocked", async () => {
|
||||
@ -68,21 +73,23 @@ describe("State Migration Service", () => {
|
||||
};
|
||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
||||
|
||||
storageService.get(userId, Arg.any()).resolves(accountVersion3);
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3);
|
||||
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
|
||||
expect(storageService.save).toHaveBeenCalledTimes(2);
|
||||
expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any());
|
||||
});
|
||||
|
||||
it("updates StateVersion number", async () => {
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
storageService.received(1).save(
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
Arg.is((globals: GlobalState) => globals.stateVersion === StateVersion.Four),
|
||||
Arg.any()
|
||||
{ stateVersion: StateVersion.Four },
|
||||
any()
|
||||
);
|
||||
expect(storageService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -144,4 +151,65 @@ describe("State Migration Service", () => {
|
||||
expect(migratedAccount.keys.legacyEtmKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 6 to 7 migration", () => {
|
||||
it("should delete global.noAutoPromptBiometrics value", async () => {
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]);
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
{
|
||||
stateVersion: StateVersion.Seven,
|
||||
},
|
||||
any()
|
||||
);
|
||||
});
|
||||
|
||||
it("should call migrateStateFrom6To7 on each account", async () => {
|
||||
const accountVersion6 = new Account({
|
||||
otherStuff: "other stuff",
|
||||
} as any);
|
||||
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6);
|
||||
|
||||
const migrateSpy = jest.fn();
|
||||
(stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy;
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6);
|
||||
});
|
||||
|
||||
it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../types/csprng";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
pbkdf2: (
|
||||
@ -65,5 +66,5 @@ export abstract class CryptoFunctionService {
|
||||
) => Promise<ArrayBuffer>;
|
||||
rsaExtractPublicKey: (privateKey: ArrayBuffer) => Promise<ArrayBuffer>;
|
||||
rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[ArrayBuffer, ArrayBuffer]>;
|
||||
randomBytes: (length: number) => Promise<ArrayBuffer>;
|
||||
randomBytes: (length: number) => Promise<CsprngArray>;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { CollectionView } from "../admin-console/models/view/collection.view";
|
||||
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfConfig } from "../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../auth/types/biometric-key";
|
||||
import { KdfType, ThemeType, UriMatchType } from "../enums";
|
||||
import { EventData } from "../models/data/event.data";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
@ -78,7 +79,7 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setCryptoMasterKeyB64: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>;
|
||||
hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
|
||||
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
|
||||
@ -164,8 +165,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setEnableAlwaysOnTop: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableAutoFillOnPageLoad: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableAutoFillOnPageLoad: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBiometric: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBiometric: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
|
||||
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
|
||||
@ -293,8 +292,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: any }>;
|
||||
setNeverDomains: (value: { [id: string]: any }, options?: StorageOptions) => Promise<void>;
|
||||
getNoAutoPromptBiometrics: (options?: StorageOptions) => Promise<boolean>;
|
||||
setNoAutoPromptBiometrics: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNoAutoPromptBiometricsText: (options?: StorageOptions) => Promise<string>;
|
||||
setNoAutoPromptBiometricsText: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
||||
|
6
libs/common/src/auth/types/biometric-key.d.ts
vendored
Normal file
6
libs/common/src/auth/types/biometric-key.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
import { CsprngString } from "../../types/csprng";
|
||||
|
||||
export type BiometricKey = {
|
||||
key: string;
|
||||
clientEncKeyHalf: CsprngString;
|
||||
};
|
@ -7,3 +7,28 @@ export enum EncryptionType {
|
||||
Rsa2048_OaepSha256_HmacSha256_B64 = 5,
|
||||
Rsa2048_OaepSha1_HmacSha256_B64 = 6,
|
||||
}
|
||||
|
||||
/** The expected number of parts to a serialized EncString of the given encryption type.
|
||||
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
|
||||
* AesCbc128_HmacSha256_B64 will have 3 parts.
|
||||
*
|
||||
* Example of annotated serialized EncStrings:
|
||||
* 0.iv|data
|
||||
* 1.iv|data|mac
|
||||
* 2.iv|data|mac
|
||||
* 3.data
|
||||
* 4.data
|
||||
*
|
||||
* @see EncString
|
||||
* @see EncryptionType
|
||||
* @see EncString.parseEncryptedString
|
||||
*/
|
||||
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
|
||||
[EncryptionType.AesCbc256_B64]: 2,
|
||||
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
|
||||
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
|
||||
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
|
||||
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,
|
||||
[EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64]: 2,
|
||||
[EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64]: 2,
|
||||
};
|
||||
|
@ -5,5 +5,6 @@ export enum StateVersion {
|
||||
Four = 4, // Fix 'Never Lock' option by removing stale data
|
||||
Five = 5, // Migrate to new storage of encrypted organization keys
|
||||
Six = 6, // Delete account.keys.legacyEtmKey property
|
||||
Latest = Six,
|
||||
Seven = 7, // Remove global desktop auto prompt setting, move to account
|
||||
Latest = Seven,
|
||||
}
|
||||
|
@ -133,27 +133,20 @@ export class AccountKeys {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
new AccountKeys(),
|
||||
{ cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey) },
|
||||
{
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
},
|
||||
{ organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys) },
|
||||
{ providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys) },
|
||||
{
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
},
|
||||
{
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
}
|
||||
);
|
||||
return Object.assign(new AccountKeys(), {
|
||||
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
|
||||
cryptoSymmetricKey: EncryptionPair.fromJSON(
|
||||
obj?.cryptoSymmetricKey,
|
||||
SymmetricCryptoKey.fromJSON
|
||||
),
|
||||
organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys),
|
||||
providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys),
|
||||
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
|
||||
obj?.privateKey,
|
||||
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
|
||||
),
|
||||
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
static initRecordEncryptionPairsFromJSON(obj: any) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../enums";
|
||||
import { IEncrypted } from "../../interfaces/IEncrypted";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
@ -75,34 +75,26 @@ export class EncString implements IEncrypted {
|
||||
return;
|
||||
}
|
||||
|
||||
const { encType, encPieces } = this.parseEncryptedString(this.encryptedString);
|
||||
const { encType, encPieces } = EncString.parseEncryptedString(this.encryptedString);
|
||||
this.encryptionType = encType;
|
||||
|
||||
if (encPieces.length !== EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
case EncryptionType.AesCbc256_HmacSha256_B64:
|
||||
if (encPieces.length !== 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
this.mac = encPieces[2];
|
||||
break;
|
||||
case EncryptionType.AesCbc256_B64:
|
||||
if (encPieces.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iv = encPieces[0];
|
||||
this.data = encPieces[1];
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
if (encPieces.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data = encPieces[0];
|
||||
break;
|
||||
default:
|
||||
@ -110,7 +102,7 @@ export class EncString implements IEncrypted {
|
||||
}
|
||||
}
|
||||
|
||||
private parseEncryptedString(encryptedString: string): {
|
||||
private static parseEncryptedString(encryptedString: string): {
|
||||
encType: EncryptionType;
|
||||
encPieces: string[];
|
||||
} {
|
||||
@ -139,6 +131,12 @@ export class EncString implements IEncrypted {
|
||||
};
|
||||
}
|
||||
|
||||
static isSerializedEncString(s: string): boolean {
|
||||
const { encType, encPieces } = this.parseEncryptedString(s);
|
||||
|
||||
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
|
||||
}
|
||||
|
||||
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
|
||||
if (this.decryptedValue != null) {
|
||||
return this.decryptedValue;
|
||||
|
@ -24,7 +24,6 @@ export class GlobalState {
|
||||
mainWindowSize?: number;
|
||||
enableBiometrics?: boolean;
|
||||
biometricText?: string;
|
||||
noAutoPromptBiometrics?: boolean;
|
||||
noAutoPromptBiometricsText?: string;
|
||||
stateVersion: StateVersion = StateVersion.One;
|
||||
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
|
||||
|
@ -62,12 +62,16 @@ export class SymmetricCryptoKey {
|
||||
return { keyB64: this.keyB64 };
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
|
||||
if (obj == null) {
|
||||
static fromString(s: string): SymmetricCryptoKey {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = Utils.fromB64ToArray(obj.keyB64).buffer;
|
||||
const arrayBuffer = Utils.fromB64ToArray(s).buffer;
|
||||
return new SymmetricCryptoKey(arrayBuffer);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
|
||||
return SymmetricCryptoKey.fromString(obj?.keyB64);
|
||||
}
|
||||
}
|
||||
|
@ -30,8 +30,8 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class CryptoService implements CryptoServiceAbstraction {
|
||||
constructor(
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private encryptService: EncryptService,
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected encryptService: EncryptService,
|
||||
protected platformUtilService: PlatformUtilsService,
|
||||
protected logService: LogService,
|
||||
protected stateService: StateService
|
||||
@ -716,16 +716,19 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
// ---HELPERS---
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
} else if (await this.shouldStoreKey(KeySuffixOptions.Biometric, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key.keyB64, { userId: userId });
|
||||
const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId);
|
||||
|
||||
if (storeAuto) {
|
||||
await this.storeAutoKey(key, userId);
|
||||
} else {
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
protected async storeAutoKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
let shouldStoreKey = false;
|
||||
if (keySuffix === KeySuffixOptions.Auto) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
import { Jsonify, JsonValue } from "type-fest";
|
||||
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
|
||||
@ -18,6 +18,7 @@ import { CollectionView } from "../admin-console/models/view/collection.view";
|
||||
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
|
||||
import { KdfConfig } from "../auth/models/domain/kdf-config";
|
||||
import { BiometricKey } from "../auth/types/biometric-key";
|
||||
import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType, UriMatchType } from "../enums";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { StateFactory } from "../factories/stateFactory";
|
||||
@ -607,7 +608,7 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async setCryptoMasterKeyBiometric(value: string, options?: StorageOptions): Promise<void> {
|
||||
async setCryptoMasterKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(
|
||||
this.reconcileOptions(options, { keySuffix: "biometric" }),
|
||||
await this.defaultSecureStorageOptions()
|
||||
@ -1136,24 +1137,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBiometric(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.enableBiometrics ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setEnableBiometric(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
globals.enableBiometrics = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@ -1876,24 +1859,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getNoAutoPromptBiometrics(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.noAutoPromptBiometrics ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setNoAutoPromptBiometrics(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
globals.noAutoPromptBiometrics = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getNoAutoPromptBiometricsText(options?: StorageOptions): Promise<string> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
@ -2848,7 +2813,11 @@ export class StateService<
|
||||
return this.reconcileOptions(options, defaultOptions);
|
||||
}
|
||||
|
||||
private async saveSecureStorageKey(key: string, value: string, options?: StorageOptions) {
|
||||
private async saveSecureStorageKey<T extends JsonValue>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?: StorageOptions
|
||||
) {
|
||||
return value == null
|
||||
? await this.secureStorageService.remove(`${options.userId}${key}`, options)
|
||||
: await this.secureStorageService.save(`${options.userId}${key}`, value, options);
|
||||
|
@ -174,6 +174,22 @@ export class StateMigrationService<
|
||||
await this.setCurrentStateVersion(StateVersion.Six);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Six: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
const globals = (await this.getGlobals()) as any;
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom6To7(
|
||||
globals?.noAutoPromptBiometrics,
|
||||
account
|
||||
);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
if (globals) {
|
||||
delete globals.noAutoPromptBiometrics;
|
||||
}
|
||||
await this.set(keys.global, globals);
|
||||
await this.setCurrentStateVersion(StateVersion.Seven);
|
||||
}
|
||||
}
|
||||
|
||||
currentStateVersion += 1;
|
||||
@ -204,7 +220,7 @@ export class StateMigrationService<
|
||||
// 1. Check for an existing storage value from the old storage structure OR
|
||||
// 2. Check for a value already set by processes that run before migration OR
|
||||
// 3. Assign the default value
|
||||
const globals =
|
||||
const globals: any =
|
||||
(await this.get<GlobalState>(keys.global)) ?? this.stateFactory.createGlobal(null);
|
||||
globals.stateVersion = StateVersion.Two;
|
||||
globals.environmentUrls =
|
||||
@ -525,6 +541,16 @@ export class StateMigrationService<
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom6To7(
|
||||
globalSetting: boolean,
|
||||
account: TAccount
|
||||
): Promise<TAccount> {
|
||||
if (globalSetting) {
|
||||
account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
protected get options(): StorageOptions {
|
||||
return { htmlStorageLocation: HtmlStorageLocation.Local };
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { CryptoFunctionService } from "../abstractions/cryptoFunction.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { DecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../types/csprng";
|
||||
|
||||
export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
private crypto: Crypto;
|
||||
@ -350,10 +351,10 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return [publicKey, privateKey];
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<ArrayBuffer> {
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
const arr = new Uint8Array(length);
|
||||
this.crypto.getRandomValues(arr);
|
||||
return Promise.resolve(arr.buffer);
|
||||
return Promise.resolve(arr.buffer as CsprngArray);
|
||||
}
|
||||
|
||||
private toBuf(value: string | ArrayBuffer): ArrayBuffer {
|
||||
|
5
libs/common/src/types/csprng.d.ts
vendored
Normal file
5
libs/common/src/types/csprng.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
type CsprngArray = Opaque<ArrayBuffer, "CSPRNG">;
|
||||
|
||||
type CsprngString = Opaque<string, "CSPRNG">;
|
@ -7,6 +7,7 @@ import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunc
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { DecryptParameters } from "@bitwarden/common/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
|
||||
export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
pbkdf2(
|
||||
@ -270,13 +271,13 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
|
||||
});
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<ArrayBuffer> {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
return new Promise<CsprngArray>((resolve, reject) => {
|
||||
crypto.randomBytes(length, (error, bytes) => {
|
||||
if (error != null) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(this.toArrayBuffer(bytes));
|
||||
resolve(this.toArrayBuffer(bytes) as CsprngArray);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user