Rework Desktop Biometrics (#5234)

This commit is contained in:
Matt Gibson 2023-04-18 09:09:47 -04:00 committed by GitHub
parent 4852992662
commit 830af7b06d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2497 additions and 564 deletions

View File

@ -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

View File

@ -1,3 +1,3 @@
{
"cSpell.words": ["Popout", "Reprompt", "takeuntil"]
"cSpell.words": ["Csprng", "Popout", "Reprompt", "takeuntil"]
}

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
}
}

View File

@ -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");
}
}

View File

@ -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>;
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View 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",
}
}
}

View 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 })
}

View File

@ -0,0 +1,5 @@
pub use cipher_string::*;
pub use crypto::*;
mod cipher_string;
mod crypto;

View 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>;

View File

@ -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,
}
}

View File

@ -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">

View File

@ -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, {

View File

@ -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 {}

View File

@ -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"

View File

@ -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();
}
}
}

View File

@ -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."

View File

@ -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(

View File

@ -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;
}
}
}

View File

@ -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,
};
}
}

View File

@ -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>;
}

View File

@ -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();
});
});
});

View File

@ -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.");
}
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -331,6 +331,10 @@ form,
}
}
.form-group-child {
margin-left: 20px;
}
.checkbox {
position: relative;
display: block;

View File

@ -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;
}
}
}

View File

@ -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",

View File

@ -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>;
}

View 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())
);
}
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
export enum BiometricStorageAction {
EnabledForUser = "enabled",
OsSupported = "osSupported",
}
export type BiometricMessage = {
action: BiometricStorageAction;
keySuffix?: string;
key?: string;
userId?: string;
};

View File

@ -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"));
});
});

View File

@ -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");

View File

@ -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);
});

View File

@ -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",
});
});
});
});

View File

@ -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>;
}

View File

@ -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>;

View File

@ -0,0 +1,6 @@
import { CsprngString } from "../../types/csprng";
export type BiometricKey = {
key: string;
clientEncKeyHalf: CsprngString;
};

View File

@ -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,
};

View File

@ -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,
}

View File

@ -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) {

View File

@ -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;

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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);

View File

@ -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 };
}

View File

@ -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
View File

@ -0,0 +1,5 @@
import { Opaque } from "type-fest";
type CsprngArray = Opaque<ArrayBuffer, "CSPRNG">;
type CsprngString = Opaque<string, "CSPRNG">;

View File

@ -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);
}
});
});