1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 03:33:30 +00:00

Merge branch 'main' into beeep/dev-container

This commit is contained in:
Conner Turnbull
2026-02-04 10:06:48 -05:00
committed by GitHub
49 changed files with 1449 additions and 249 deletions

View File

@@ -3035,10 +3035,6 @@
"custom": {
"message": "Custom"
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createSend": {
"message": "New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -6144,5 +6140,9 @@
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
}
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -28,7 +26,7 @@ export default class WebRequestBackground {
this.webRequest.onAuthRequired.addListener(
(async (
details: chrome.webRequest.OnAuthRequiredDetails,
callback: (response: chrome.webRequest.BlockingResponse) => void,
callback: (response: chrome.webRequest.BlockingResponse | null) => void,
) => {
if (!details.url || this.pendingAuthRequests.has(details.requestId)) {
if (callback) {
@@ -51,16 +49,16 @@ export default class WebRequestBackground {
);
this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), {
urls: ["http://*/*"],
urls: ["http://*/*", "https://*/*"],
});
this.webRequest.onErrorOccurred.addListener((details) => this.completeAuthRequest(details), {
urls: ["http://*/*"],
urls: ["http://*/*", "https://*/*"],
});
}
private async resolveAuthCredentials(
domain: string,
success: (response: chrome.webRequest.BlockingResponse) => void,
success: (response: chrome.webRequest.BlockingResponse | null) => void,
// eslint-disable-next-line
error: Function,
) {
@@ -82,7 +80,7 @@ export default class WebRequestBackground {
const ciphers = await this.cipherService.getAllDecryptedForUrl(
domain,
activeUserId,
null,
undefined,
UriMatchStrategy.Host,
);
if (ciphers == null || ciphers.length !== 1) {
@@ -90,10 +88,17 @@ export default class WebRequestBackground {
return;
}
const username = ciphers[0].login?.username;
const password = ciphers[0].login?.password;
if (username == null || password == null) {
error();
return;
}
success({
authCredentials: {
username: ciphers[0].login.username,
password: ciphers[0].login.password,
username,
password,
},
});
} catch {

View File

@@ -512,9 +512,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "camino"

View File

@@ -27,7 +27,7 @@ ashpd = "=0.12.0"
base64 = "=0.22.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
byteorder = "=1.5.0"
bytes = "=1.11.0"
bytes = "=1.11.1"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"

View File

@@ -19,20 +19,18 @@ use tracing::{debug, warn};
use zbus::Connection;
use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject};
use crate::secure_memory::*;
use crate::secure_memory::{encrypted_memory_store::EncryptedMemoryStore, SecureMemoryStore as _};
pub struct BiometricLockSystem {
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to
// ensure locked vaults cannot be unlocked
secure_memory: Arc<Mutex<crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore>>,
secure_memory: Arc<Mutex<EncryptedMemoryStore<String>>>,
}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {
secure_memory: Arc::new(Mutex::new(
crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(),
)),
secure_memory: Arc::new(Mutex::new(EncryptedMemoryStore::default())),
}
}
}
@@ -64,7 +62,7 @@ impl super::BiometricTrait for BiometricLockSystem {
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
async fn unlock(&self, user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
if !polkit_authenticate_bitwarden_policy().await? {
return Err(anyhow!("Authentication failed"));
}
@@ -72,11 +70,11 @@ impl super::BiometricTrait for BiometricLockSystem {
self.secure_memory
.lock()
.await
.get(user_id)
.get(user_id)?
.ok_or(anyhow!("No key found"))
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
async fn unlock_available(&self, user_id: &String) -> Result<bool> {
Ok(self.secure_memory.lock().await.has(user_id))
}
@@ -84,7 +82,7 @@ impl super::BiometricTrait for BiometricLockSystem {
Ok(false)
}
async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> {
async fn unenroll(&self, user_id: &String) -> Result<(), anyhow::Error> {
self.secure_memory.lock().await.remove(user_id);
Ok(())
}

View File

@@ -21,14 +21,17 @@ pub trait BiometricTrait: Send + Sync {
/// enrollment, this function should do nothing.
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>;
/// Clear the persistent and ephemeral keys
async fn unenroll(&self, user_id: &str) -> Result<()>;
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
async fn unenroll(&self, user_id: &String) -> Result<()>;
/// Check if a persistent (survives app restarts and reboots) key is set for a user
async fn has_persistent(&self, user_id: &str) -> Result<bool>;
/// Provide a key to be ephemerally held. This should be called on every unlock.
async fn provide_key(&self, user_id: &str, key: &[u8]);
/// Perform biometric unlock and return the key
async fn unlock(&self, user_id: &str, hwnd: Vec<u8>) -> Result<Vec<u8>>;
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
async fn unlock(&self, user_id: &String, hwnd: Vec<u8>) -> Result<Vec<u8>>;
/// Check if biometric unlock is available based on whether a key is present and whether
/// authentication is possible
async fn unlock_available(&self, user_id: &str) -> Result<bool>;
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
async fn unlock_available(&self, user_id: &String) -> Result<bool>;
}

View File

@@ -29,11 +29,11 @@ impl super::BiometricTrait for BiometricLockSystem {
unimplemented!()
}
async fn unlock(&self, _user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
async fn unlock(&self, _user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
unimplemented!()
}
async fn unlock_available(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
async fn unlock_available(&self, _user_id: &String) -> Result<bool, anyhow::Error> {
unimplemented!()
}
@@ -41,7 +41,7 @@ impl super::BiometricTrait for BiometricLockSystem {
unimplemented!()
}
async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> {
async fn unenroll(&self, _user_id: &String) -> Result<(), anyhow::Error> {
unimplemented!()
}
}

View File

@@ -110,7 +110,7 @@ impl super::BiometricTrait for BiometricLockSystem {
}
}
async fn unenroll(&self, user_id: &str) -> Result<()> {
async fn unenroll(&self, user_id: &String) -> Result<()> {
self.secure_memory.lock().await.remove(user_id);
delete_keychain_entry(user_id).await
}
@@ -148,7 +148,7 @@ impl super::BiometricTrait for BiometricLockSystem {
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
async fn unlock(&self, user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
// Allow restoring focus to the previous window (browser)
let previous_active_window = super::windows_focus::get_active_window();
let _focus_scopeguard = scopeguard::guard((), |_| {
@@ -164,8 +164,7 @@ impl super::BiometricTrait for BiometricLockSystem {
if secure_memory.has(user_id) {
if windows_hello_authenticate("Unlock your vault".to_string()).await? {
secure_memory
.get(user_id)
.clone()
.get(user_id)?
.ok_or_else(|| anyhow!("No key found for user"))
} else {
Err(anyhow!("Authentication failed"))
@@ -186,7 +185,7 @@ impl super::BiometricTrait for BiometricLockSystem {
}
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
async fn unlock_available(&self, user_id: &String) -> Result<bool> {
let secure_memory = self.secure_memory.lock().await;
let has_key =
secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false);
@@ -435,7 +434,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test_double_unenroll() {
let user_id = "test_user";
let user_id = String::from("test_user");
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
@@ -443,34 +442,34 @@ mod tests {
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.enroll_persistent(&user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.has_persistent(&user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.unlock(&user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.has_persistent(&user_id)
.await
.unwrap());
println!("Unenrolling user again");
// This throws PASSWORD_NOT_FOUND but our code should handle that and not throw.
windows_hello_lock_system.unenroll(user_id).await.unwrap();
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.has_persistent(&user_id)
.await
.unwrap());
}
@@ -478,7 +477,7 @@ mod tests {
#[tokio::test]
#[ignore]
async fn test_enroll_unlock_unenroll() {
let user_id = "test_user";
let user_id = String::from("test_user");
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
@@ -486,25 +485,25 @@ mod tests {
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.enroll_persistent(&user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.has_persistent(&user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.unlock(&user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.has_persistent(&user_id)
.await
.unwrap());
}

View File

@@ -9,7 +9,7 @@ pub mod ipc;
pub mod password;
pub mod powermonitor;
pub mod process_isolation;
pub(crate) mod secure_memory;
pub mod secure_memory;
pub mod ssh_agent;
use zeroizing_alloc::ZeroAlloc;

View File

@@ -5,7 +5,7 @@ use windows::Win32::Security::Cryptography::{
CRYPTPROTECTMEMORY_SAME_PROCESS,
};
use crate::secure_memory::SecureMemoryStore;
use crate::secure_memory::{DecryptionError, SecureMemoryStore};
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
@@ -26,7 +26,9 @@ impl DpapiSecretKVStore {
}
impl SecureMemoryStore for DpapiSecretKVStore {
fn put(&mut self, key: String, value: &[u8]) {
type KeyType = String;
fn put(&mut self, key: Self::KeyType, value: &[u8]) {
let length_header_len = std::mem::size_of::<usize>();
// The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it
@@ -55,8 +57,8 @@ impl SecureMemoryStore for DpapiSecretKVStore {
self.map.insert(key, padded_data);
}
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
self.map.get(key).map(|data| {
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError> {
if let Some(data) = self.map.get(key) {
// A copy is created, that is then mutated by the DPAPI unprotect function.
let mut data = data.clone();
unsafe {
@@ -77,15 +79,19 @@ impl SecureMemoryStore for DpapiSecretKVStore {
.expect("length header should be usize"),
);
data[length_header_size..length_header_size + data_length].to_vec()
})
Ok(Some(
data[length_header_size..length_header_size + data_length].to_vec(),
))
} else {
Ok(None)
}
}
fn has(&self, key: &str) -> bool {
fn has(&self, key: &Self::KeyType) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
fn remove(&mut self, key: &Self::KeyType) {
self.map.remove(key);
}
@@ -113,7 +119,7 @@ mod tests {
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert_eq!(
store.get(&key),
store.get(&key).expect("entry in map for key"),
Some(value),
"Value mismatch for size {}",
size
@@ -128,7 +134,7 @@ mod tests {
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
assert_eq!(store.get(&key).expect("entry in map for key"), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}

View File

@@ -1,7 +1,9 @@
use std::collections::BTreeMap;
use tracing::error;
use crate::secure_memory::{
secure_key::{EncryptedMemory, SecureMemoryEncryptionKey},
secure_key::{DecryptionError, EncryptedMemory, SecureMemoryEncryptionKey},
SecureMemoryStore,
};
@@ -12,50 +14,87 @@ use crate::secure_memory::{
///
/// The key is briefly in process memory during encryption and decryption, in memory that is
/// protected from swapping to disk via mlock, and then zeroed out immediately after use.
#[allow(unused)]
pub(crate) struct EncryptedMemoryStore {
map: std::collections::HashMap<String, EncryptedMemory>,
/// # Type Parameters
///
/// * `K` - The type of the key.
pub struct EncryptedMemoryStore<K>
where
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
{
map: BTreeMap<K, EncryptedMemory>,
memory_encryption_key: SecureMemoryEncryptionKey,
}
impl EncryptedMemoryStore {
#[allow(unused)]
pub(crate) fn new() -> Self {
impl<K> EncryptedMemoryStore<K>
where
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
{
#[must_use]
pub fn new() -> Self {
EncryptedMemoryStore {
map: std::collections::HashMap::new(),
map: BTreeMap::new(),
memory_encryption_key: SecureMemoryEncryptionKey::new(),
}
}
/// # Returns
///
/// An array of all decrypted values.
/// Due to the usage of `BtreeMap`, the order is deterministic.
///
/// # Errors
///
/// `DecryptionError` if an error occured during decryption
pub fn to_vec(&mut self) -> Result<Vec<Vec<u8>>, DecryptionError> {
let mut result = vec![];
let keys: Vec<_> = self.map.keys().cloned().collect();
for key in &keys {
let bytes = self.get(key)?.expect("All keys to still be in map.");
result.push(bytes);
}
Ok(result)
}
}
impl SecureMemoryStore for EncryptedMemoryStore {
fn put(&mut self, key: String, value: &[u8]) {
impl<K> Default for EncryptedMemoryStore<K>
where
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
{
fn default() -> Self {
Self::new()
}
}
impl<K> SecureMemoryStore for EncryptedMemoryStore<K>
where
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
{
type KeyType = K;
fn put(&mut self, key: Self::KeyType, value: &[u8]) {
let encrypted_value = self.memory_encryption_key.encrypt(value);
self.map.insert(key, encrypted_value);
}
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
let encrypted_memory = self.map.get(key);
if let Some(encrypted_memory) = encrypted_memory {
match self.memory_encryption_key.decrypt(encrypted_memory) {
Ok(plaintext) => Some(plaintext),
Err(_) => {
error!("In memory store, decryption failed for key {}. The memory may have been tampered with. re-keying.", key);
self.memory_encryption_key = SecureMemoryEncryptionKey::new();
self.clear();
None
}
}
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError> {
if let Some(encrypted) = self.map.get(key) {
self.memory_encryption_key.decrypt(encrypted).map_err(|error| {
error!(?error, %key, "In memory store, decryption failed. The memory may have been tampered with. Re-keying.");
self.memory_encryption_key = SecureMemoryEncryptionKey::new();
self.clear();
error
}).map(Some)
} else {
None
Ok(None)
}
}
fn has(&self, key: &str) -> bool {
fn has(&self, key: &Self::KeyType) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
fn remove(&mut self, key: &Self::KeyType) {
self.map.remove(key);
}
@@ -64,7 +103,10 @@ impl SecureMemoryStore for EncryptedMemoryStore {
}
}
impl Drop for EncryptedMemoryStore {
impl<K> Drop for EncryptedMemoryStore<K>
where
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
{
fn drop(&mut self) {
self.clear();
}
@@ -76,30 +118,98 @@ mod tests {
#[test]
fn test_secret_kv_store_various_sizes() {
let mut store = EncryptedMemoryStore::new();
let mut store = EncryptedMemoryStore::default();
for size in 0..=2048 {
let key = format!("test_key_{}", size);
let key = format!("test_key_{size}");
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert!(store.has(&key), "Store should have key for size {size}");
assert_eq!(
store.get(&key),
store.get(&key).expect("entry in map for key"),
Some(value),
"Value mismatch for size {}",
size
"Value mismatch for size {size}",
);
}
}
#[test]
fn test_crud() {
let mut store = EncryptedMemoryStore::new();
let mut store = EncryptedMemoryStore::default();
let key = "test_key".to_string();
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
assert_eq!(store.get(&key).expect("entry in map for key"), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}
#[test]
fn test_to_vec_contains_all() {
let mut store = EncryptedMemoryStore::default();
for size in 0..=2048 {
let key = format!("test_key_{size}");
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
}
let vec_values = store.to_vec().expect("decryption to not fail");
// to_vec() should contain same number of values as inserted
assert_eq!(vec_values.len(), 2049);
// the value from the store should match the value in the vec
let keys: Vec<_> = store.map.keys().cloned().collect();
for (store_key, vec_value) in keys.iter().zip(vec_values.iter()) {
let store_value = store.get(store_key).expect("entry in map for key").unwrap();
assert_eq!(&store_value, vec_value);
store.remove(store_key);
}
// all values were present
assert!(store.map.is_empty());
}
#[test]
fn test_to_vec_preserves_sorted_key_order() {
let mut store = EncryptedMemoryStore::new();
// insert in non-sorted order
store.put("morpheus", &[4, 5, 6]);
store.put("trinity", &[1, 2, 3]);
store.put("dozer", &[7, 8, 9]);
store.put("neo", &[10, 11, 12]);
let vec = store.to_vec().expect("decryption to not fail");
assert_eq!(
vec,
vec![
vec![7, 8, 9], // dozer
vec![4, 5, 6], // morpheus
vec![10, 11, 12], // neo
vec![1, 2, 3], // trinity
]
);
}
#[test]
fn test_to_vec_order_after_remove() {
let mut store = EncryptedMemoryStore::new();
// insert in non-sorted order
store.put("trinity", &[3]);
store.put("morpheus", &[1]);
store.put("neo", &[2]);
let vec = store.to_vec().expect("decryption to not fail");
assert_eq!(vec, vec![vec![1], vec![2], vec![3]]);
store.remove(&"neo");
let vec = store.to_vec().expect("decryption to not fail");
assert_eq!(vec, vec![vec![1], vec![3]]);
}
}

View File

@@ -4,24 +4,35 @@ pub(crate) mod dpapi;
pub(crate) mod encrypted_memory_store;
mod secure_key;
pub use encrypted_memory_store::EncryptedMemoryStore;
use crate::secure_memory::secure_key::DecryptionError;
/// The secure memory store provides an ephemeral key-value store for sensitive data.
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
/// platform-specific protections are applied to prevent memory dumps or debugger access from
/// reading the stored values.
#[allow(unused)]
pub(crate) trait SecureMemoryStore {
pub trait SecureMemoryStore {
type KeyType;
/// Stores a copy of the provided value in secure memory.
fn put(&mut self, key: String, value: &[u8]);
fn put(&mut self, key: Self::KeyType, value: &[u8]);
/// Retrieves a copy of the value associated with the given key from secure memory.
/// This copy does not have additional memory protections applied, and should be zeroed when no
/// longer needed.
///
/// Note: If memory was tampered with, this will re-key the store and return None.
fn get(&mut self, key: &str) -> Option<Vec<u8>>;
/// # Errors
///
/// `DecryptionError` if memory is tampered with. This also re-keys the memory store.
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError>;
/// Checks if a value is stored under the given key.
fn has(&self, key: &str) -> bool;
fn has(&self, key: &Self::KeyType) -> bool;
/// Removes the value associated with the given key from secure memory.
fn remove(&mut self, key: &str);
fn remove(&mut self, key: &Self::KeyType);
/// Clears all values stored in secure memory.
fn clear(&mut self);
}

View File

@@ -77,10 +77,20 @@ impl AsRef<[u8]> for MemoryEncryptionKey {
}
#[derive(Debug)]
pub(crate) enum DecryptionError {
pub enum DecryptionError {
CouldNotDecrypt,
}
impl std::error::Error for DecryptionError {}
impl std::fmt::Display for DecryptionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DecryptionError::CouldNotDecrypt => write!(f, "Could not decrypt"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -19,9 +19,7 @@ mod keyctl;
mod memfd_secret;
mod mlock;
pub use crypto::EncryptedMemory;
use crate::secure_memory::secure_key::crypto::DecryptionError;
pub use crypto::{DecryptionError, EncryptedMemory};
/// An ephemeral key that is protected using a platform mechanism. It is generated on construction
/// freshly, and can be used to encrypt and decrypt segments of memory. Since the key is ephemeral,

View File

@@ -137,10 +137,6 @@
"message": "Send details",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendTypeTextToShare": {
"message": "Text to share"
},
@@ -4587,5 +4583,9 @@
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
}
}

View File

@@ -6,65 +6,80 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
(onAddFolder)="addFolder()"
[showPremiumCallout]="showPremiumCallout$ | async"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
@if (!!action) {
<div class="details">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
@if (action === "view") {
<app-cipher-view [cipher]="cipher" [collections]="collections"> </app-cipher-view>
}
@if (action === "add" || action === "edit" || action === "clone") {
<vault-cipher-form
#vaultForm
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
@if (!["add", "edit", "view", "clone"].includes(action)) {
<div id="logo" class="logo">
<div class="content">
<div class="inner-content">
@if (activeFilter.isArchived && !(hasArchivedCiphers$ | async)) {
<bit-no-items [icon]="itemTypesIcon">
<div slot="title">
{{ "noItemsInArchive" | i18n }}
</div>
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
{{ "noItemsInArchiveDesc" | i18n }}
</p>
</bit-no-items>
} @else {
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
</div>
</div>
</div>
</div>
}
</div>
<ng-template #folderAddEdit></ng-template>

View File

@@ -18,6 +18,7 @@ import { filter, map, take } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { ItemTypes } from "@bitwarden/assets/svg";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -58,6 +59,7 @@ import {
ToastService,
CopyClickListener,
COPY_CLICK_LISTENER,
NoItemsModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -112,6 +114,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
ButtonModule,
PremiumBadgeComponent,
VaultItemsV2Component,
NoItemsModule,
],
providers: [
{
@@ -154,7 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
type: CipherType | null = null;
folderId: string | null | undefined = null;
collectionId: string | null = null;
organizationId: string | null = null;
organizationId: OrganizationId | null = null;
myVaultOnly = false;
addType: CipherType | undefined = undefined;
addOrganizationId: string | null = null;
@@ -168,9 +171,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
cipher: CipherView | null = new CipherView();
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
showPremiumCallout$: Observable<boolean> = this.userId$.pipe(
switchMap((userId) =>
combineLatest([
this.routedVaultFilterBridgeService.activeFilter$,
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
),
);
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
protected itemTypesIcon = ItemTypes;
private organizations$: Observable<Organization[]> = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
@@ -178,10 +191,9 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
switchMap((id) => this.organizationService.organizations$(id)),
);
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
filter((account): account is Account => !!account),
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
protected hasArchivedCiphers$ = this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
),
);

View File

@@ -95,5 +95,13 @@
{{ itemTypes.labelKey | i18n }}
</button>
}
@if (desktopMigrationMilestone1()) {
<bit-menu-divider />
<button type="button" bitMenuItem (click)="onAddFolder.emit()">
<i class="bwi bwi-folder tw-mr-1" aria-hidden="true"></i>
{{ "folder" | i18n }}
</button>
}
</bit-menu>
</ng-template>

View File

@@ -1,15 +1,15 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component, input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, input, output } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { distinctUntilChanged, debounceTime } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
@@ -32,7 +32,12 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
})
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
readonly showPremiumCallout = input<boolean>(false);
readonly organizationId = input<OrganizationId | undefined>(undefined);
readonly onAddFolder = output<void>();
protected readonly desktopMigrationMilestone1 = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone1),
);
protected CipherViewLikeUtils = CipherViewLikeUtils;
@@ -55,7 +60,7 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
}
async navigateToGetPremium() {
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
await this.premiumUpgradePromptService.promptForPremium();
}
trackByFn(index: number, c: C): string {

View File

@@ -7,7 +7,6 @@
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
[showPremiumCallout]="showPremiumCallout$ | async"
[organizationId]="organizationId"
>
</app-vault-items-v2>
@if (!!action) {

View File

@@ -1,6 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
// @ts-strict-ignore
export class EmergencyAccessPasswordRequest {
newMasterPasswordHash: string;
key: string;
// This will eventually be changed to be an actual constructor, once all callers are updated.
// The body of this request will be changed to carry the authentication data and unlock data.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
): EmergencyAccessPasswordRequest {
const request = new EmergencyAccessPasswordRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
return request;
}
}

View File

@@ -7,8 +7,17 @@ import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationData,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { newGuid } from "@bitwarden/guid";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
Argon2KdfConfig,
DEFAULT_KDF_CONFIG,
KdfType,
KeyService,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
@@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => {
let cipherService: MockProxy<CipherService>;
let logService: MockProxy<LogService>;
let emergencyAccessService: EmergencyAccessService;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let configService: MockProxy<ConfigService>;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
@@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => {
encryptService = mock<EncryptService>();
cipherService = mock<CipherService>();
logService = mock<LogService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
configService = mock<ConfigService>();
emergencyAccessService = new EmergencyAccessService(
emergencyAccessApiService,
@@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => {
encryptService,
cipherService,
logService,
masterPasswordService,
configService,
);
});
@@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => {
});
});
describe("takeover", () => {
/**
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties
* in the test setup above that are now un-used and can also be removed.
*/
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false;
const params = {
id: "emergencyAccessId",
masterPassword: "mockPassword",
@@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => {
);
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
);
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
@@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => {
});
});
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
// Mock feature flag value
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true;
// Mock sut method params
const id = "emergency-access-id";
const masterPassword = "mockPassword";
const email = "user@example.com";
const activeUserId = newGuid() as UserId;
// Mock method data
const kdfConfig = DEFAULT_KDF_CONFIG;
const takeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: kdfConfig.kdfType,
kdfIterations: kdfConfig.iterations,
} as EmergencyAccessTakeoverResponse;
const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey;
let mockGrantorUserKey: UserKey;
let salt: MasterPasswordSalt;
let authenticationData: MasterPasswordAuthenticationData;
let unlockData: MasterPasswordUnlockData;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
);
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse);
keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey));
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey);
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
salt = email as MasterPasswordSalt;
masterPasswordService.emailToSalt.mockReturnValue(salt);
authenticationData = {
salt,
kdf: kdfConfig,
masterPasswordAuthenticationHash:
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
};
unlockData = {
salt,
kdf: kdfConfig,
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
} as MasterPasswordUnlockData;
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
authenticationData,
);
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
});
it("should throw if active user private key is not found", async () => {
// Arrange
keyService.userPrivateKey$.mockReturnValue(of(null));
// Act
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
await expect(promise).rejects.toThrow(
"Active user does not have a private key, cannot complete a takeover.",
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => {
// Arrange
encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
// Act
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
await expect(promise).rejects.toThrow("Failed to decrypt grantor key");
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
});
it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
kdfConfig, // default config (PBKDF2)
salt,
);
});
it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => {
// Arrange
const argon2TakeoverResponse = {
keyEncrypted: "EncryptedKey",
kdf: KdfType.Argon2id,
kdfIterations: 3,
kdfMemory: 64,
kdfParallelism: 4,
} as EmergencyAccessTakeoverResponse;
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(
argon2TakeoverResponse,
);
const expectedKdfConfig = new Argon2KdfConfig(
argon2TakeoverResponse.kdfIterations,
argon2TakeoverResponse.kdfMemory,
argon2TakeoverResponse.kdfParallelism,
);
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
expectedKdfConfig,
salt,
);
expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith(
masterPassword,
kdfConfig, // default config (PBKDF2)
salt,
);
});
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
masterPassword,
kdfConfig,
salt,
);
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
masterPassword,
kdfConfig,
salt,
mockGrantorUserKey,
);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
id,
request,
);
});
it("should call the API method to change the grantor's master password", async () => {
// Act
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
// Assert
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1);
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
id,
request,
);
});
});
describe("getRotatedData", () => {
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,

View File

@@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordAuthenticationData,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
@@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
private encryptService: EncryptService,
private cipherService: CipherService,
private logService: LogService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private configService: ConfigService,
) {}
/**
@@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
* Intended for grantee.
* @param id emergency access id
* @param masterPassword new master password
* @param email email address of grantee (must be consistent or login will fail)
* @param email email address of grantor (must be consistent or login will fail)
* @param activeUserId the user id of the active user
*/
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
@@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
break;
}
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
const authenticationData: MasterPasswordAuthenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
config,
salt,
);
const unlockData: MasterPasswordUnlockData =
await this.masterPasswordService.makeMasterPasswordUnlockData(
masterPassword,
config,
salt,
grantorUserKey,
);
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
return; // EARLY RETURN for flagged logic
}
const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config);
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);

View File

@@ -5645,10 +5645,6 @@
"sendTypeText": {
"message": "Text"
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createSend": {
"message": "New Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -12782,5 +12778,12 @@
},
"invalidSendPassword": {
"message": "Invalid Send password"
},
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"perUser": {
"message": "per user"
}
}

View File

@@ -1,10 +1,14 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
button: {
text: string;
type: ButtonType;
icon?: { type: BitwardenIcon; position: "before" | "after" };
};
features: string[];
};

View File

@@ -94,7 +94,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
private configService: ConfigService,
protected configService: ConfigService,
) {
this.subscribeToCiphers();

View File

@@ -1092,7 +1092,7 @@ describe("Cipher Service", () => {
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
expect(apiSpy).toHaveBeenCalled();
expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
});
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {

View File

@@ -1422,7 +1422,7 @@ export class CipherService implements CipherServiceAbstraction {
return;
}
const request = new CipherBulkDeleteRequest(ids);
const request = new CipherBulkDeleteRequest(ids, orgId);
if (asAdmin) {
await this.apiService.deleteManyCiphersAdmin(request);
} else {

View File

@@ -1,4 +1,5 @@
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
export { BitwardenIcon } from "./shared/icon";
export * from "./a11y";
export * from "./anon-layout";
export * from "./async-actions";

View File

@@ -2,6 +2,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import * as sdkInternal from "@bitwarden/sdk-internal";
import { APICredentialsData } from "../spec-data/onepassword-1pux/api-credentials";
import { BankAccountData } from "../spec-data/onepassword-1pux/bank-account";
@@ -25,11 +26,14 @@ import { SanitizedExport } from "../spec-data/onepassword-1pux/sanitized-export"
import { SecureNoteData } from "../spec-data/onepassword-1pux/secure-note";
import { ServerData } from "../spec-data/onepassword-1pux/server";
import { SoftwareLicenseData } from "../spec-data/onepassword-1pux/software-license";
import { SSH_KeyData } from "../spec-data/onepassword-1pux/ssh-key";
import { SSNData } from "../spec-data/onepassword-1pux/ssn";
import { WirelessRouterData } from "../spec-data/onepassword-1pux/wireless-router";
import { OnePassword1PuxImporter } from "./onepassword-1pux-importer";
jest.mock("@bitwarden/sdk-internal");
function validateCustomField(fields: FieldView[], fieldName: string, expectedValue: any) {
expect(fields).toBeDefined();
const customField = fields.find((f) => f.name === fieldName);
@@ -669,6 +673,37 @@ describe("1Password 1Pux Importer", () => {
validateCustomField(cipher.fields, "medication notes", "multiple times a day");
});
it("should parse category 114 - SSH Key", async () => {
// Mock the SDK import_ssh_key function to return converted OpenSSH format
const mockConvertedKey = {
privateKey:
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRswAAAJh8F3bYfBd2\n2AAAAAtzc2gtZWQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRsw\nAAAEA59QYE22f+VFHhiyH1Vfqiwz7xLEt1zCuk8M8Ng5LpKpayncUVVUKwZ3beGxxGQM98\nbMpnzPVX9kH2fNt0MVGzAAAAE3Rlc3RAZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
} as sdkInternal.SshKeyView;
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(mockConvertedKey);
const importer = new OnePassword1PuxImporter();
const jsonString = JSON.stringify(SSH_KeyData);
const result = await importer.parse(jsonString);
expect(result != null).toBe(true);
const cipher = result.ciphers.shift();
expect(cipher.type).toEqual(CipherType.SshKey);
expect(cipher.name).toEqual("Some SSH Key");
expect(cipher.notes).toEqual("SSH Key Note");
// Verify that import_ssh_key was called with the PKCS#8 key from 1Password
expect(sdkInternal.import_ssh_key).toHaveBeenCalledWith(
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
);
// Verify the key was converted to OpenSSH format
expect(cipher.sshKey.privateKey).toEqual(mockConvertedKey.privateKey);
expect(cipher.sshKey.publicKey).toEqual(mockConvertedKey.publicKey);
expect(cipher.sshKey.keyFingerprint).toEqual(mockConvertedKey.fingerprint);
});
it("should create folders", async () => {
const importer = new OnePassword1PuxImporter();
const result = await importer.parse(SanitizedExportJson);

View File

@@ -8,6 +8,8 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { import_ssh_key } from "@bitwarden/sdk-internal";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
@@ -80,6 +82,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
cipher.type = CipherType.Identity;
cipher.identity = new IdentityView();
break;
case Category.SSH_Key:
cipher.type = CipherType.SshKey;
cipher.sshKey = new SshKeyView();
break;
default:
break;
}
@@ -316,6 +322,19 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
default:
break;
}
} else if (cipher.type === CipherType.SshKey) {
if (valueKey === "sshKey") {
// Use sshKey.metadata.privateKey instead of the sshKey.privateKey field.
// The sshKey.privateKey field doesn't have a consistent format for every item.
const { privateKey } = field.value.sshKey.metadata;
// Convert SSH key from PKCS#8 (1Password format) to OpenSSH format using SDK
// Note: 1Password does not store password-protected SSH keys, so no password handling needed for now
const parsedKey = import_ssh_key(privateKey);
cipher.sshKey.privateKey = parsedKey.privateKey;
cipher.sshKey.publicKey = parsedKey.publicKey;
cipher.sshKey.keyFingerprint = parsedKey.fingerprint;
return;
}
}
if (valueKey === "email") {

View File

@@ -49,6 +49,7 @@ export const Category = Object.freeze({
EmailAccount: "111",
API_Credential: "112",
MedicalRecord: "113",
SSH_Key: "114",
} as const);
/**
@@ -133,6 +134,7 @@ export interface Value {
creditCardType?: string | null;
creditCardNumber?: string | null;
reference?: string | null;
sshKey?: SSHKey | null;
}
export interface Email {
@@ -147,6 +149,19 @@ export interface Address {
zip: string;
state: string;
}
export interface SSHKey {
privateKey: string;
metadata: SSHKeyMetadata;
}
export interface SSHKeyMetadata {
privateKey: string;
publicKey: string;
fingerprint: string;
keyType: string;
}
export interface InputTraits {
keyboard: string;
correction: string;

View File

@@ -0,0 +1,83 @@
import { ExportData } from "../../onepassword/types/onepassword-1pux-importer-types";
export const SSH_KeyData: ExportData = {
accounts: [
{
attrs: {
accountName: "1Password Customer",
name: "1Password Customer",
avatar: "",
email: "username123123123@gmail.com",
uuid: "TRIZ3XV4JJFRXJ3BARILLTUA6E",
domain: "https://my.1password.com/",
},
vaults: [
{
attrs: {
uuid: "pqcgbqjxr4tng2hsqt5ffrgwju",
desc: "Just test entries",
avatar: "ke7i5rxnjrh3tj6uesstcosspu.png",
name: "T's Test Vault",
type: "U",
},
items: [
{
uuid: "kf7wevmfiqmbgyao42plvgrasy",
favIndex: 0,
createdAt: 1724868152,
updatedAt: 1724868152,
state: "active",
categoryUuid: "114",
details: {
loginFields: [],
notesPlain: "SSH Key Note",
sections: [
{
title: "SSH Key Section",
fields: [
{
title: "private key",
id: "private_key",
value: {
sshKey: {
privateKey:
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
metadata: {
privateKey:
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
publicKey:
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
keyType: "ed25519",
},
},
},
guarded: true,
multiline: false,
dontGenerate: false,
inputTraits: {
keyboard: "default",
correction: "default",
capitalization: "default",
},
},
],
hideAddAnotherField: true,
},
],
passwordHistory: [],
},
overview: {
subtitle: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
icons: null,
title: "Some SSH Key",
url: "",
watchtowerExclusions: null,
},
},
],
},
],
},
],
};

View File

@@ -16,7 +16,7 @@
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
</h2>
<span bitTypography="h3">&nbsp;</span>
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
<span bitTypography="body1" class="tw-text-main tw-font-normal">/ {{ term }}</span>
}
</div>
<button
@@ -38,21 +38,36 @@
<!-- Password Manager Section -->
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
<div class="tw-flex tw-justify-between tw-mb-1">
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "passwordManager" | i18n }}
</h3>
</div>
<!-- Password Manager Members -->
<div id="password-manager-members" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
@let passwordManagerSeats = cart.passwordManager.seats;
<div bitTypography="body1" class="tw-text-muted">
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ passwordManagerSeats.quantity }}
{{
translateWithParams(
passwordManagerSeats.translationKey,
passwordManagerSeats.translationParams
)
}}
@if (!passwordManagerSeats.hideBreakdown) {
x
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="password-manager-total"
>
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -62,13 +77,25 @@
@if (additionalStorage) {
<div id="additional-storage" class="tw-flex tw-justify-between">
<div class="tw-flex-1">
<div bitTypography="body1" class="tw-text-muted">
{{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalStorage.quantity }}
{{
translateWithParams(
additionalStorage.translationKey,
additionalStorage.translationParams
)
}}
@if (!additionalStorage.hideBreakdown) {
x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
{{ term }}
}
</div>
</div>
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="additional-storage-total"
>
{{ additionalStorageTotal() | currency: "USD" : "symbol" }}
</div>
</div>
@@ -80,19 +107,30 @@
@if (secretsManagerSeats) {
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
<div class="tw-flex tw-justify-between">
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
<div bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "secretsManager" | i18n }}
</div>
</div>
<!-- Secrets Manager Members -->
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ secretsManagerSeats.quantity }}
{{
translateWithParams(
secretsManagerSeats.translationKey,
secretsManagerSeats.translationParams
)
}}
@if (!secretsManagerSeats.hideBreakdown) {
x
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
/ {{ term }}
}
</div>
<div
bitTypography="body1"
class="tw-text-muted"
class="tw-text-muted tw-font-normal"
data-testid="secrets-manager-seats-total"
>
{{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }}
@@ -103,12 +141,20 @@
@let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts;
@if (additionalServiceAccounts) {
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
<div bitTypography="body1" class="tw-text-muted">
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ additionalServiceAccounts.quantity }}
{{ additionalServiceAccounts.translationKey | i18n }} x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
{{
translateWithParams(
additionalServiceAccounts.translationKey,
additionalServiceAccounts.translationParams
)
}}
@if (!additionalServiceAccounts.hideBreakdown) {
x
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
/
{{ term }}
}
</div>
<div
bitTypography="body1"
@@ -129,19 +175,41 @@
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="discount-section"
>
<h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3>
<div bitTypography="body1" class="tw-text-success-600">{{ discountLabel() }}</div>
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
-{{ discountAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Credit -->
@if (creditAmount() > 0) {
<div
id="credit-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
data-testid="credit-section"
>
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
{{ translateWithParams(cart.credit!.translationKey, cart.credit!.translationParams) }}
</div>
<div
bitTypography="body1"
class="tw-text-muted tw-font-normal"
data-testid="credit-amount"
>
-{{ creditAmount() | currency: "USD" : "symbol" }}
</div>
</div>
}
<!-- Estimated Tax -->
<div
id="estimated-tax-section"
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
>
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
{{ "estimatedTax" | i18n }}
</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
{{ estimatedTax() | currency: "USD" : "symbol" }}
</div>
@@ -149,7 +217,7 @@
<!-- Total -->
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3>
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
</div>

View File

@@ -25,8 +25,10 @@ behavior across Bitwarden applications.
- [With Secrets Manager](#with-secrets-manager)
- [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts)
- [All Products](#all-products)
- [With Account Credit](#with-account-credit)
- [With Percent Discount](#with-percent-discount)
- [With Amount Discount](#with-amount-discount)
- [With Discount and Credit](#with-discount-and-credit)
- [Custom Header Template](#custom-header-template)
- [Premium Plan](#premium-plan)
- [Families Plan](#families-plan)
@@ -85,9 +87,16 @@ export type Cart = {
};
cadence: "annually" | "monthly"; // Billing period for entire cart
discount?: Discount; // Optional cart-level discount
credit?: Credit; // Optional account credit
estimatedTax: number; // Tax amount
};
export type Credit = {
translationKey: string; // Translation key for credit label
translationParams?: Array<string | number>; // Optional params for translation
value: number; // Credit amount to subtract from subtotal
};
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
export type Discount = {
@@ -330,6 +339,33 @@ Show a cart with all available products:
</billing-cart-summary>
```
### With Account Credit
Show cart with account credit applied:
<Canvas of={CartSummaryStories.WithCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
}
},
cadence: 'monthly',
credit: {
translationKey: 'accountCredit',
value: 25.00
},
estimatedTax: 10.00
}"
>
</billing-cart-summary>
```
### With Percent Discount
Show cart with percentage-based discount:
@@ -396,6 +432,42 @@ Show cart with fixed amount discount:
</billing-cart-summary>
```
### With Discount and Credit
Show cart with both discount and credit applied:
<Canvas of={CartSummaryStories.WithDiscountAndCredit} />
```html
<billing-cart-summary
[cart]="{
passwordManager: {
seats: {
quantity: 5,
translationKey: 'members',
cost: 50.00
},
additionalStorage: {
quantity: 2,
translationKey: 'additionalStorageGB',
cost: 10.00
}
},
cadence: 'annually',
discount: {
type: 'percent-off',
value: 15
},
credit: {
translationKey: 'accountCredit',
value: 50.00
},
estimatedTax: 15.00
}"
>
</billing-cart-summary>
```
### Custom Header Template
Show cart with custom header template:
@@ -466,10 +538,12 @@ Show cart with families plan:
- **Collapsible Interface**: Users can toggle between a summary view showing only the total and a
detailed view showing all line items
- **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager)
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using
Angular signals and computed values
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, credits, taxes, and
totals using Angular signals and computed values
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
styling
- **Credit Support**: Shows account credit deductions with clear labeling using i18n translation
keys
- **Custom Header Templates**: Optional header input allows for custom header designs while
maintaining cart functionality
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts

View File

@@ -89,6 +89,8 @@ describe("CartSummaryComponent", () => {
return "Premium membership";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}
@@ -253,6 +255,126 @@ describe("CartSummaryComponent", () => {
});
});
describe("hideBreakdown Property", () => {
it("should hide cost breakdown when hideBreakdown is true for password manager seats", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Act / Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members");
});
it("should show cost breakdown when hideBreakdown is false for password manager seats", () => {
// Arrange / Act
const pmLineItem = fixture.debugElement.query(
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
);
// Assert
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
});
it("should hide cost breakdown for additional storage when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
passwordManager: {
...mockCart.passwordManager,
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
const storageLineItem = storageItem.query(By.css(".tw-flex-1 .tw-text-muted"));
const storageTotal = storageItem.query(By.css("[data-testid='additional-storage-total']"));
// Act / Assert
expect(storageLineItem.nativeElement.textContent).toContain("2 Additional storage GB");
expect(storageTotal.nativeElement.textContent).toContain("$20.00");
});
it("should hide cost breakdown for secrets manager seats when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: {
quantity: 3,
translationKey: "secretsManagerSeats",
cost: 30,
hideBreakdown: true,
},
additionalServiceAccounts: mockCart.secretsManager!.additionalServiceAccounts,
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const smLineItem = fixture.debugElement.query(
By.css('[id="secrets-manager-members"] .tw-text-muted'),
);
const smTotal = fixture.debugElement.query(
By.css('[data-testid="secrets-manager-seats-total"]'),
);
// Act / Assert
expect(smLineItem.nativeElement.textContent).toContain("3 Secrets Manager seats");
expect(smTotal.nativeElement.textContent).toContain("$90.00");
});
it("should hide cost breakdown for additional service accounts when hideBreakdown is true", () => {
// Arrange
const cartWithHiddenBreakdown: Cart = {
...mockCart,
secretsManager: {
seats: mockCart.secretsManager!.seats,
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6,
hideBreakdown: true,
},
},
};
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
fixture.detectChanges();
const saLineItem = fixture.debugElement.query(
By.css('[id="additional-service-accounts"] .tw-text-muted'),
);
const saTotal = fixture.debugElement.query(
By.css('[data-testid="additional-service-accounts-total"]'),
);
// Act / Assert
expect(saLineItem.nativeElement.textContent).toContain("2 Additional machine accounts");
expect(saTotal.nativeElement.textContent).toContain("$12.00");
});
});
describe("Discount Display", () => {
it("should not display discount section when no discount is present", () => {
// Arrange / Act
@@ -336,6 +458,94 @@ describe("CartSummaryComponent", () => {
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
describe("Credit Display", () => {
it("should not display credit section when no credit is present", () => {
// Arrange / Act
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
// Assert
expect(creditSection).toBeFalsy();
});
it("should display credit correctly", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 25.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const creditLabel = creditSection.query(By.css("h3"));
const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]'));
// Act / Assert
expect(creditSection).toBeTruthy();
expect(creditLabel.nativeElement.textContent.trim()).toBe("accountCredit");
expect(creditAmount.nativeElement.textContent).toContain("-$25.00");
});
it("should apply credit to total calculation", () => {
// Arrange
const cartWithCredit: Cart = {
...mockCart,
credit: {
translationKey: "accountCredit",
value: 50.0,
},
};
fixture.componentRef.setInput("cart", cartWithCredit);
fixture.detectChanges();
// Subtotal = 372, credit = 50, tax = 9.6
// Total = 372 - 50 + 9.6 = 331.6
const expectedTotal = "$331.60";
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
it("should display and apply both discount and credit correctly", () => {
// Arrange
const cartWithBoth: Cart = {
...mockCart,
discount: {
type: DiscountTypes.PercentOff,
value: 10,
},
credit: {
translationKey: "accountCredit",
value: 30.0,
},
};
fixture.componentRef.setInput("cart", cartWithBoth);
fixture.detectChanges();
// Subtotal = 372, discount = 37.2 (10%), credit = 30, tax = 9.6
// Total = 372 - 37.2 - 30 + 9.6 = 314.4
const expectedTotal = "$314.40";
const discountSection = fixture.debugElement.query(
By.css('[data-testid="discount-section"]'),
);
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
const topTotal = fixture.debugElement.query(By.css("h2"));
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
// Act / Assert
expect(discountSection).toBeTruthy();
expect(creditSection).toBeTruthy();
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
});
});
});
describe("CartSummaryComponent - Custom Header Template", () => {
@@ -424,6 +634,8 @@ describe("CartSummaryComponent - Custom Header Template", () => {
return "Collapse purchase details";
case "discount":
return "discount";
case "accountCredit":
return "accountCredit";
default:
return key;
}

View File

@@ -57,6 +57,8 @@ export default {
return "Your next charge is for";
case "dueOn":
return "due on";
case "premiumSubscriptionCredit":
return "Premium subscription credit";
default:
return key;
}
@@ -341,3 +343,92 @@ export const WithAmountDiscount: Story = {
} satisfies Cart,
},
};
export const WithHiddenBreakdown: Story = {
name: "Hidden Cost Breakdown",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
hideBreakdown: true,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
hideBreakdown: true,
},
},
secretsManager: {
seats: {
quantity: 3,
translationKey: "members",
cost: 30.0,
hideBreakdown: true,
},
additionalServiceAccounts: {
quantity: 2,
translationKey: "additionalServiceAccountsV2",
cost: 6.0,
hideBreakdown: true,
},
},
cadence: "monthly",
estimatedTax: 19.2,
} satisfies Cart,
},
};
export const WithCredit: Story = {
name: "With Account Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
},
cadence: "monthly",
credit: {
translationKey: "premiumSubscriptionCredit",
value: 25.0,
},
estimatedTax: 10.0,
} satisfies Cart,
},
};
export const WithDiscountAndCredit: Story = {
name: "With Both Discount and Credit",
args: {
cart: {
passwordManager: {
seats: {
quantity: 5,
translationKey: "members",
cost: 50.0,
},
additionalStorage: {
quantity: 2,
translationKey: "additionalStorageGB",
cost: 10.0,
},
},
cadence: "annually",
discount: {
type: DiscountTypes.PercentOff,
value: 15,
},
credit: {
translationKey: "premiumSubscriptionCredit",
value: 50.0,
},
estimatedTax: 15.0,
} satisfies Cart,
},
};

View File

@@ -142,11 +142,22 @@ export class CartSummaryComponent {
return getLabel(this.i18nService, discount);
});
/**
* Calculates the credit amount from the cart credit
*/
readonly creditAmount = computed<number>(() => {
const { credit } = this.cart();
if (!credit) {
return 0;
}
return credit.value;
});
/**
* Calculates the total of all line items including discount and tax
*/
readonly total = computed<number>(
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
() => this.subtotal() - this.discountAmount() - this.creditAmount() + this.estimatedTax(),
);
/**
@@ -154,6 +165,16 @@ export class CartSummaryComponent {
*/
readonly total$ = toObservable(this.total);
/**
* Translates a key with optional parameters
*/
translateWithParams(key: string, params?: Array<string | number>): string {
if (!params || params.length === 0) {
return this.i18nService.t(key);
}
return this.i18nService.t(key, ...params);
}
/**
* Toggles the expanded/collapsed state of the cart items
*/

View File

@@ -22,22 +22,24 @@
@if (price(); as priceValue) {
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<!-- Show no decimals for whole numbers (e.g. $5), but always show 2 decimals when present (e.g. $120.50) -->
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
priceValue.amount | currency: "$"
priceValue.amount
| currency: "$" : true : (priceValue.amount % 1 === 0 ? "1.0-0" : "1.2-2")
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ priceValue.cadence }}
/ {{ priceValue.cadence | i18n }}
@if (priceValue.showPerUser) {
per user
{{ "perUser" | i18n }}
}
</span>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
@if (button(); as buttonConfig) {
<!-- Button -->
@if (button(); as buttonConfig) {
<div class="tw-mb-6 tw-h-12">
<button
bitButton
[buttonType]="buttonConfig.type"
@@ -46,19 +48,19 @@
(click)="buttonClick.emit()"
type="button"
>
@if (buttonConfig.icon?.position === "before") {
<i class="bwi {{ buttonConfig.icon.type }} tw-me-2" aria-hidden="true"></i>
@if (buttonConfig.icon && buttonConfig.icon.position === "before") {
<bit-icon [name]="buttonConfig.icon.type" class="tw-me-2" aria-hidden="true"></bit-icon>
}
{{ buttonConfig.text }}
@if (
buttonConfig.icon &&
(buttonConfig.icon.position === "after" || !buttonConfig.icon.position)
) {
<i class="bwi {{ buttonConfig.icon.type }} tw-ms-2" aria-hidden="true"></i>
<bit-icon [name]="buttonConfig.icon.type" class="tw-ms-2" aria-hidden="true"></bit-icon>
}
</button>
}
</div>
</div>
}
<!-- Features List -->
<div class="tw-flex-grow">
@@ -67,10 +69,12 @@
<ul class="tw-list-none tw-p-0 tw-m-0">
@for (feature of featureList; track feature) {
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
<i
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
<bit-icon
name="bwi-check"
class="tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
aria-hidden="true"
></i>
>
</bit-icon>
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
feature
}}</span>

View File

@@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing";
| Input | Type | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) |
| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" |
| `features` | `string[]` | **Optional.** List of features with checkmarks |
| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown |
@@ -182,6 +182,58 @@ For coming soon or unavailable plans:
</billing-pricing-card>
```
### With Button Icons
Add icons to buttons for enhanced visual communication:
<Canvas of={PricingCardStories.WithButtonIcon} />
```html
<!-- Icon after text (default) -->
<billing-pricing-card
title="Premium Plan"
tagline="Upgrade for advanced features"
[price]="{ amount: 10, cadence: 'monthly' }"
[button]="{
text: 'Upgrade Now',
type: 'primary',
icon: { type: 'bwi-external-link', position: 'after' }
}"
[features]="premiumFeatures"
>
</billing-pricing-card>
<!-- Icon before text -->
<billing-pricing-card
title="Business Plan"
tagline="Add more features to your plan"
[price]="{ amount: 5, cadence: 'monthly', showPerUser: true }"
[button]="{
text: 'Add Features',
type: 'secondary',
icon: { type: 'bwi-plus', position: 'before' }
}"
[features]="businessFeatures"
>
</billing-pricing-card>
```
### Active Plan Badge
Show which plan is currently active:
<Canvas of={PricingCardStories.ActivePlan} />
```html
<billing-pricing-card
title="Free Plan"
tagline="Your current plan with essential features"
[features]="freeFeatures"
[activeBadge]="{ text: 'Active plan' }"
>
</billing-pricing-card>
```
### Pricing Grid Layout
Multiple cards displayed together:

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
@@ -69,6 +70,29 @@ describe("PricingCardComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}).compileComponents();
// For signal inputs, we need to set required inputs through the host component
@@ -151,7 +175,7 @@ describe("PricingCardComponent", () => {
it("should display bwi-check icons for features", () => {
hostFixture.detectChanges();
const compiled = hostFixture.nativeElement;
const icons = compiled.querySelectorAll("i.bwi-check");
const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']");
expect(icons.length).toBe(3); // One for each feature
});

View File

@@ -1,15 +1,42 @@
import { Meta, StoryObj } from "@storybook/angular";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { TypographyModule } from "@bitwarden/components";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SvgModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { PricingCardComponent } from "./pricing-card.component";
export default {
title: "Billing/Pricing Card",
component: PricingCardComponent,
moduleMetadata: {
imports: [TypographyModule],
},
decorators: [
moduleMetadata({
imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe],
providers: [
{
provide: I18nService,
useValue: {
t: (key: string) => {
switch (key) {
case "month":
return "month";
case "monthly":
return "monthly";
case "year":
return "year";
case "annually":
return "annually";
case "perUser":
return "per user";
default:
return key;
}
},
},
},
],
}),
],
args: {
tagline: "Everything you need for secure password management across all your devices",
},
@@ -83,7 +110,7 @@ export const WithoutFeatures: Story = {
}),
args: {
tagline: "Advanced security and management for your organization",
price: { amount: 3, cadence: "monthly" },
price: { amount: 3, cadence: "month" },
button: { text: "Contact Sales", type: "primary" },
},
};
@@ -150,7 +177,7 @@ export const LongTagline: Story = {
args: {
tagline:
"Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business",
price: { amount: 5, cadence: "monthly", showPerUser: true },
price: { amount: 5, cadence: "month", showPerUser: true },
button: { text: "Start Business Trial", type: "primary" },
features: [
"Everything in Premium",
@@ -274,7 +301,7 @@ export const WithoutButton: Story = {
}),
args: {
tagline: "This plan will be available soon with exciting new features",
price: { amount: 15, cadence: "monthly" },
price: { amount: 15, cadence: "month" },
features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"],
},
};

View File

@@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core
import {
BadgeModule,
BadgeVariant,
BitwardenIcon,
ButtonModule,
ButtonType,
CardComponent,
IconModule,
SvgModule,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
/**
* A reusable UI-only component that displays pricing information in a card format.
@@ -20,20 +23,29 @@ import {
selector: "billing-pricing-card",
templateUrl: "./pricing-card.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent],
imports: [
BadgeModule,
ButtonModule,
SvgModule,
IconModule,
TypographyModule,
CurrencyPipe,
CardComponent,
I18nPipe,
],
})
export class PricingCardComponent {
readonly tagline = input.required<string>();
readonly price = input<{
amount: number;
cadence: "monthly" | "annually";
cadence: "month" | "monthly" | "year" | "annually";
showPerUser?: boolean;
}>();
readonly button = input<{
type: ButtonType;
text: string;
disabled?: boolean;
icon?: { type: string; position: "before" | "after" };
icon?: { type: BitwardenIcon; position: "before" | "after" };
}>();
readonly features = input<string[]>();
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();

View File

@@ -1,10 +1,14 @@
import { Discount } from "@bitwarden/pricing";
import { Credit } from "./credit";
export type CartItem = {
translationKey: string;
translationParams?: Array<string | number>;
quantity: number;
cost: number;
discount?: Discount;
hideBreakdown?: boolean;
};
export type Cart = {
@@ -18,5 +22,6 @@ export type Cart = {
};
cadence: "annually" | "monthly";
discount?: Discount;
credit?: Credit;
estimatedTax: number;
};

View File

@@ -0,0 +1,5 @@
export type Credit = {
translationKey: string;
translationParams?: Array<string | number>;
value: number;
};

View File

@@ -61,6 +61,9 @@
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
<bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint>
}
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
<bit-hint class="tw-mt-2">{{ "sendPasswordHelperText" | i18n }}</bit-hint>
}
</bit-form-field>
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
@@ -108,7 +111,6 @@
></button>
}
</div>
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
</bit-form-field>
}

View File

@@ -312,7 +312,7 @@ export class SendDetailsComponent implements OnInit {
const emails = control.value.split(",").map((e: string) => e.trim());
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e));
return invalidEmails.length > 0 ? { email: true } : null;
return invalidEmails.length > 0 ? { multipleEmails: true } : null;
};
}

8
package-lock.json generated
View File

@@ -160,7 +160,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",
@@ -36683,9 +36683,9 @@
}
},
"node_modules/prettier": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -127,7 +127,7 @@
"path-browserify": "1.0.1",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-tailwindcss": "0.7.1",
"process": "0.11.10",
"remark-gfm": "4.0.1",