1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 17:53:41 +00:00

Merge branch 'main' into chromium-import-abe

This commit is contained in:
adudek-bw
2025-10-23 08:51:22 -04:00
committed by GitHub
167 changed files with 3099 additions and 299 deletions

1
.github/CODEOWNERS vendored
View File

@@ -8,6 +8,7 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml

View File

@@ -400,7 +400,16 @@
reviewers: ["team:team-vault-dev"],
},
{
matchPackageNames: ["aes", "big-integer", "cbc", "rsa", "russh-cryptovec", "sha2"],
matchPackageNames: [
"aes",
"big-integer",
"cbc",
"rsa",
"russh-cryptovec",
"sha2",
"memsec",
"linux-keyutils",
],
description: "Key Management owned dependencies",
commitMessagePrefix: "[deps] KM:",
reviewers: ["team:team-key-management-dev"],

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ npm-debug.log
# Build directories
dist
build
target
.angular/cache
.flatpak
.flatpak-repo

View File

@@ -33,6 +33,8 @@ import { AccountComponent } from "./account.component";
import { CurrentAccountComponent } from "./current-account.component";
import { AccountSwitcherService } from "./services/account-switcher.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account-switcher.component.html",
imports: [

View File

@@ -13,13 +13,19 @@ import { BiometricsService } from "@bitwarden/key-management";
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-account",
templateUrl: "account.component.html",
imports: [CommonModule, JslibModule, AvatarModule, ItemModule],
})
export class AccountComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() account: AvailableAccount;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() loading = new EventEmitter<boolean>();
constructor(

View File

@@ -21,6 +21,8 @@ export type CurrentAccount = {
avatarColor: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-current-account",
templateUrl: "current-account.component.html",

View File

@@ -13,6 +13,8 @@ import {
IconButtonModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "set-pin.component.html",
imports: [

View File

@@ -41,6 +41,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
import { AccountSecurityComponent } from "./account-security.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-pop-out",
template: ` <ng-content></ng-content>`,

View File

@@ -78,6 +78,8 @@ import { SetPinComponent } from "../components/set-pin.component";
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account-security.component.html",
imports: [

View File

@@ -3,6 +3,8 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "await-desktop-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -7,6 +7,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
standalone: true,
selector: "extension-device-management",

View File

@@ -28,9 +28,17 @@ import {
],
})
export class Fido2CipherRowComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onSelected = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() cipher: CipherView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() last: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
protected selectCipher(c: CipherView) {

View File

@@ -15,6 +15,8 @@ import { MenuModule } from "@bitwarden/components";
import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data";
import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-fido2-use-browser-link",
templateUrl: "fido2-use-browser-link.component.html",

View File

@@ -71,6 +71,8 @@ interface ViewData {
fallbackSupported: boolean;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-fido2",
templateUrl: "fido2.component.html",

View File

@@ -77,6 +77,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "autofill.component.html",
imports: [

View File

@@ -41,6 +41,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-blocked-domains",
templateUrl: "blocked-domains.component.html",
@@ -66,6 +68,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
],
})
export class BlockedDomainsComponent implements AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
new QueryList();

View File

@@ -42,6 +42,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-excluded-domains",
templateUrl: "excluded-domains.component.html",
@@ -67,6 +69,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
],
})
export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
new QueryList();

View File

@@ -21,6 +21,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "notifications.component.html",
imports: [

View File

@@ -1050,6 +1050,7 @@ export default class MainBackground {
this.authService,
this.stateProvider,
this.securityStateService,
this.kdfConfigService,
);
this.syncServiceListener = new SyncServiceListener(

View File

@@ -267,6 +267,11 @@ export class ItemMoreOptionsComponent {
}
protected async delete() {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: { key: "deleteItemConfirmation" },

View File

@@ -846,6 +846,7 @@ export class ServiceContainer {
this.authService,
this.stateProvider,
this.securityStateService,
this.kdfConfigService,
);
this.totpService = new TotpService(this.sdkService);

View File

@@ -941,6 +941,8 @@ dependencies = [
"interprocess",
"keytar",
"libc",
"linux-keyutils",
"memsec",
"oo7",
"pin-project",
"pkcs8",
@@ -1807,6 +1809,16 @@ dependencies = [
"cc",
]
[[package]]
name = "linux-keyutils"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -1892,6 +1904,17 @@ dependencies = [
"autocfg",
]
[[package]]
name = "memsec"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
dependencies = [
"getrandom 0.2.16",
"libc",
"windows-sys 0.45.0",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -4090,6 +4113,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4126,6 +4158,21 @@ dependencies = [
"windows-link 0.2.0",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -4174,6 +4221,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -4192,6 +4245,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -4210,6 +4269,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -4240,6 +4305,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -4267,6 +4338,12 @@ dependencies = [
"windows-core 0.61.0",
]
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -4285,6 +4362,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -4303,6 +4386,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -4522,9 +4611,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.1"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]

View File

@@ -39,7 +39,9 @@ homedir = "=0.3.4"
interprocess = "=2.2.1"
keytar = "=0.1.6"
libc = "=0.2.172"
linux-keyutils = "=0.2.4"
log = "=0.4.25"
memsec = "=0.7.0"
napi = "=2.16.17"
napi-build = "=2.2.0"
napi-derive = "=2.16.13"

View File

@@ -32,6 +32,7 @@ ed25519 = { workspace = true, features = ["pkcs8"] }
futures = { workspace = true }
homedir = { workspace = true }
interprocess = { workspace = true, features = ["tokio"] }
memsec = { workspace = true, features = ["alloc_ext"] }
pin-project = { workspace = true }
pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] }
rand = { workspace = true }
@@ -87,6 +88,7 @@ desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = { workspace = true }
libc = { workspace = true }
linux-keyutils = { workspace = true }
ashpd = { workspace = true }
zbus = { workspace = true, optional = true }

View File

@@ -54,7 +54,7 @@ impl SecureMemoryStore for DpapiSecretKVStore {
self.map.insert(key, padded_data);
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
self.map.get(key).map(|data| {
// A copy is created, that is then mutated by the DPAPI unprotect function.
let mut data = data.clone();

View File

@@ -0,0 +1,105 @@
use tracing::error;
use crate::secure_memory::{
secure_key::{EncryptedMemory, SecureMemoryEncryptionKey},
SecureMemoryStore,
};
/// An encrypted memory store holds a platform protected symmetric encryption key, and uses it
/// to encrypt all items it stores. The ciphertexts for the items are not specially protected. This
/// allows circumventing length and amount limitations on platform specific secure memory APIs since
/// only a single short item needs to be protected.
///
/// 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>,
memory_encryption_key: SecureMemoryEncryptionKey,
}
impl EncryptedMemoryStore {
#[allow(unused)]
pub(crate) fn new() -> Self {
EncryptedMemoryStore {
map: std::collections::HashMap::new(),
memory_encryption_key: SecureMemoryEncryptionKey::new(),
}
}
}
impl SecureMemoryStore for EncryptedMemoryStore {
fn put(&mut self, key: String, 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
}
}
} else {
None
}
}
fn has(&self, key: &str) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
fn clear(&mut self) {
self.map.clear();
}
}
impl Drop for EncryptedMemoryStore {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_kv_store_various_sizes() {
let mut store = EncryptedMemoryStore::new();
for size in 0..=2048 {
let key = format!("test_key_{}", size);
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert_eq!(
store.get(&key),
Some(value),
"Value mismatch for size {}",
size
);
}
}
#[test]
fn test_crud() {
let mut store = EncryptedMemoryStore::new();
let key = "test_key".to_string();
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}
}

View File

@@ -1,6 +1,9 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
mod encrypted_memory_store;
mod secure_key;
/// 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
@@ -12,7 +15,9 @@ pub(crate) trait SecureMemoryStore {
/// Retrieves a copy of the value associated with the given key from secure memory.
/// This copy does not have additional memory protections applied, and should be zeroed when no
/// longer needed.
fn get(&self, key: &str) -> Option<Vec<u8>>;
///
/// Note: If memory was tampered with, this will re-key the store and return None.
fn get(&mut self, key: &str) -> Option<Vec<u8>>;
/// Checks if a value is stored under the given key.
fn has(&self, key: &str) -> bool;
/// Removes the value associated with the given key from secure memory.

View File

@@ -0,0 +1,96 @@
use std::ptr::NonNull;
use chacha20poly1305::{aead::Aead, Key, KeyInit};
use rand::{rng, Rng};
pub(super) const KEY_SIZE: usize = 32;
pub(super) const NONCE_SIZE: usize = 24;
/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts will result
/// in a decryption failure and panic. The key's memory contents are protected from being swapped to disk
/// via mlock.
pub(super) struct MemoryEncryptionKey(NonNull<[u8]>);
/// An encrypted memory blob that must be decrypted using the same key that it was encrypted with.
pub struct EncryptedMemory {
nonce: [u8; NONCE_SIZE],
ciphertext: Vec<u8>,
}
impl MemoryEncryptionKey {
pub fn new() -> Self {
let mut key = [0u8; KEY_SIZE];
rng().fill(&mut key);
MemoryEncryptionKey::from(&key)
}
/// Encrypts the given plaintext using the key.
#[allow(unused)]
pub(super) fn encrypt(&self, plaintext: &[u8]) -> EncryptedMemory {
let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref()));
let mut nonce = [0u8; NONCE_SIZE];
rng().fill(&mut nonce);
let ciphertext = cipher
.encrypt(chacha20poly1305::XNonce::from_slice(&nonce), plaintext)
.expect("encryption should not fail");
EncryptedMemory { nonce, ciphertext }
}
/// Decrypts the given encrypted memory using the key. A decryption failure will panic. This is
/// okay because neither the keys nor ciphertexts should ever fail to decrypt, and doing so
/// indicates that the process memory was tampered with.
#[allow(unused)]
pub(super) fn decrypt(&self, encrypted: &EncryptedMemory) -> Result<Vec<u8>, DecryptionError> {
let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref()));
cipher
.decrypt(
chacha20poly1305::XNonce::from_slice(&encrypted.nonce),
encrypted.ciphertext.as_ref(),
)
.map_err(|_| DecryptionError::CouldNotDecrypt)
}
}
impl Drop for MemoryEncryptionKey {
fn drop(&mut self) {
unsafe {
memsec::free(self.0);
}
}
}
impl From<&[u8; KEY_SIZE]> for MemoryEncryptionKey {
fn from(value: &[u8; KEY_SIZE]) -> Self {
let mut ptr: NonNull<[u8]> =
unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") };
unsafe {
std::ptr::copy_nonoverlapping(value.as_ptr(), ptr.as_mut().as_mut_ptr(), KEY_SIZE);
}
MemoryEncryptionKey(ptr)
}
}
impl AsRef<[u8]> for MemoryEncryptionKey {
fn as_ref(&self) -> &[u8] {
unsafe { self.0.as_ref() }
}
}
#[derive(Debug)]
pub(crate) enum DecryptionError {
CouldNotDecrypt,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_encryption_key() {
let key = MemoryEncryptionKey::new();
let data = b"Hello, world!";
let encrypted = key.encrypt(data);
let decrypted = key.decrypt(&encrypted).unwrap();
assert_eq!(data.as_ref(), decrypted.as_slice());
}
}

View File

@@ -0,0 +1,93 @@
use super::crypto::{MemoryEncryptionKey, KEY_SIZE};
use super::SecureKeyContainer;
use windows::Win32::Security::Cryptography::{
CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE,
CRYPTPROTECTMEMORY_SAME_PROCESS,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
/// to the current process, and cannot be decrypted by other user-mode processes.
///
/// Note: Admin processes can still decrypt this memory:
/// https://blog.slowerzs.net/posts/cryptdecryptmemory/
pub(super) struct DpapiSecureKeyContainer {
dpapi_encrypted_key: [u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize],
}
// SAFETY: The encrypted data is fully owned by this struct, and not exposed outside or cloned,
// and is disposed on drop of this struct.
unsafe impl Send for DpapiSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for DpapiSecureKeyContainer {}
impl SecureKeyContainer for DpapiSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
let mut decrypted_key = self.dpapi_encrypted_key;
unsafe {
CryptUnprotectMemory(
decrypted_key.as_mut_ptr() as *mut core::ffi::c_void,
decrypted_key.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_unprotect_memory should work");
let mut key = [0u8; KEY_SIZE];
key.copy_from_slice(&decrypted_key[..KEY_SIZE]);
MemoryEncryptionKey::from(&key)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut padded_key = [0u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize];
padded_key[..KEY_SIZE].copy_from_slice(key.as_ref());
unsafe {
CryptProtectMemory(
padded_key.as_mut_ptr() as *mut core::ffi::c_void,
padded_key.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_protect_memory should work");
DpapiSecureKeyContainer {
dpapi_encrypted_key: padded_key,
}
}
fn is_supported() -> bool {
// DPAPI is supported on all Windows versions that we support.
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = DpapiSecureKeyContainer::from_key(key1);
let container2 = DpapiSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(DpapiSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,100 @@
use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
use linux_keyutils::{KeyRing, KeyRingIdentifier};
/// The keys are bound to the process keyring.
const KEY_RING_IDENTIFIER: KeyRingIdentifier = KeyRingIdentifier::Process;
/// This is an atomic global counter used to help generate unique key IDs
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Generates a unique ID for the key in the kernel keyring.
/// SAFETY: This function is safe to call from multiple threads because it uses an atomic counter.
fn make_id() -> String {
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// In case multiple processes are running, include the PID in the key ID.
let pid = std::process::id();
format!("bitwarden_desktop_{}_{}", pid, counter)
}
/// A secure key container that uses the Linux kernel keyctl API to store the key.
/// `https://man7.org/linux/man-pages/man1/keyctl.1.html`. The kernel enforces only
/// the correct process can read them, and they do not live in process memory space
/// and cannot be dumped.
pub(super) struct KeyctlSecureKeyContainer {
/// The kernel has an identifier for the key. This is randomly generated on construction.
id: String,
}
// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on drop.
// Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the key
// is accessible across threads within the same process bound.
unsafe impl Send for KeyctlSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for KeyctlSecureKeyContainer {}
impl SecureKeyContainer for KeyctlSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false)
.expect("should get process keyring");
let key = ring.search(&self.id).expect("should find key");
let mut buffer = [0u8; KEY_SIZE];
key.read(&mut buffer).expect("should read key");
MemoryEncryptionKey::from(&buffer)
}
fn from_key(data: MemoryEncryptionKey) -> Self {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, true)
.expect("should get process keyring");
let id = make_id();
ring.add_key(&id, &data).expect("should add key");
KeyctlSecureKeyContainer { id }
}
fn is_supported() -> bool {
KeyRing::from_special_id(KEY_RING_IDENTIFIER, true).is_ok()
}
}
impl Drop for KeyctlSecureKeyContainer {
fn drop(&mut self) {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false)
.expect("should get process keyring");
if let Ok(key) = ring.search(&self.id) {
let _ = key.invalidate();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = KeyctlSecureKeyContainer::from_key(key1);
let container2 = KeyctlSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(KeyctlSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,109 @@
use std::{ptr::NonNull, sync::LazyLock};
use super::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
/// https://man.archlinux.org/man/memfd_secret.2.en
/// The memfd_secret store protects the data using the `memfd_secret` syscall. The
/// data is inaccessible to other user-mode processes, and even to root in most cases.
/// If arbitrary data can be executed in the kernel, the data can still be retrieved:
/// https://github.com/JonathonReinhart/nosecmem
pub(super) struct MemfdSecretSecureKeyContainer {
ptr: NonNull<[u8]>,
}
// SAFETY: The pointers in this struct are allocated by `memfd_secret`, and we have full ownership.
// They are never exposed outside or cloned, and are cleaned up by drop.
unsafe impl Send for MemfdSecretSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads. Further, memfd-secret
// is accessible across threads within the same process bound.
unsafe impl Sync for MemfdSecretSecureKeyContainer {}
impl SecureKeyContainer for MemfdSecretSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
MemoryEncryptionKey::from(
&unsafe { self.ptr.as_ref() }
.try_into()
.expect("slice should be KEY_SIZE"),
)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut ptr: NonNull<[u8]> = unsafe {
memsec::memfd_secret_sized(KEY_SIZE).expect("memfd_secret_sized should work")
};
unsafe {
std::ptr::copy_nonoverlapping(
key.as_ref().as_ptr(),
ptr.as_mut().as_mut_ptr(),
KEY_SIZE,
);
}
MemfdSecretSecureKeyContainer { ptr }
}
/// Note, `memfd_secret` is only available since Linux 6.5, so fallbacks are needed.
fn is_supported() -> bool {
// To test if memfd_secret is supported, we try to allocate a 1 byte and see if that
// succeeds.
static IS_SUPPORTED: LazyLock<bool> = LazyLock::new(|| {
let Some(ptr): Option<NonNull<[u8]>> = (unsafe { memsec::memfd_secret_sized(1) })
else {
return false;
};
// Check that the pointer is readable and writable
let result = unsafe {
let ptr = ptr.as_ptr() as *mut u8;
*ptr = 30;
*ptr += 107;
*ptr == 137
};
unsafe { memsec::free_memfd_secret(ptr) };
result
});
*IS_SUPPORTED
}
}
impl Drop for MemfdSecretSecureKeyContainer {
fn drop(&mut self) {
unsafe {
memsec::free_memfd_secret(self.ptr);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = MemfdSecretSecureKeyContainer::from_key(key1);
let container2 = MemfdSecretSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(MemfdSecretSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,83 @@
use std::ptr::NonNull;
use super::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
/// A SecureKeyContainer that uses mlock to prevent the memory from being swapped to disk.
/// This does not provide as strong protections as other methods, but is always supported.
pub(super) struct MlockSecureKeyContainer {
ptr: NonNull<[u8]>,
}
// SAFETY: The pointers in this struct are allocated by `malloc_sized`, and we have full ownership.
// They are never exposed outside or cloned, and are cleaned up by drop.
unsafe impl Send for MlockSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for MlockSecureKeyContainer {}
impl SecureKeyContainer for MlockSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
MemoryEncryptionKey::from(
&unsafe { self.ptr.as_ref() }
.try_into()
.expect("slice should be KEY_SIZE"),
)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut ptr: NonNull<[u8]> =
unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") };
unsafe {
std::ptr::copy_nonoverlapping(
key.as_ref().as_ptr(),
ptr.as_mut().as_mut_ptr(),
KEY_SIZE,
);
}
MlockSecureKeyContainer { ptr }
}
fn is_supported() -> bool {
true
}
}
impl Drop for MlockSecureKeyContainer {
fn drop(&mut self) {
unsafe {
memsec::free(self.ptr);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = MlockSecureKeyContainer::from_key(key1);
let container2 = MlockSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(MlockSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,242 @@
//! This module provides hardened storage for single cryptographic keys. These are meant for encrypting large amounts of memory.
//! Some platforms restrict how many keys can be protected by their APIs, which necessitates this layer of indirection. This significantly
//! reduces the complexity of each platform specific implementation, since all that's needed is implementing protecting a single fixed sized key
//! instead of protecting many arbitrarily sized secrets. This significantly lowers the effort to maintain each implementation.
//!
//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, and a fallback implementation using mlock.
use tracing::info;
mod crypto;
#[cfg(target_os = "windows")]
mod dpapi;
#[cfg(target_os = "linux")]
mod keyctl;
#[cfg(target_os = "linux")]
mod memfd_secret;
mod mlock;
pub use crypto::EncryptedMemory;
use crate::secure_memory::secure_key::crypto::DecryptionError;
/// 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, persistent data cannot be encrypted with this key.
/// On Linux and Windows, in most cases the protection mechanisms prevent memory dumps/debuggers from reading the key.
///
/// Note: This can be circumvented if code can be injected into the process and is only effective in combination with the
/// memory isolation provided in `process_isolation`.
/// - https://github.com/zer1t0/keydump
#[allow(unused)]
pub(crate) struct SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer);
impl SecureMemoryEncryptionKey {
pub fn new() -> Self {
SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer::from_key(
crypto::MemoryEncryptionKey::new(),
))
}
/// Encrypts the provided plaintext using the contained key, returning an EncryptedMemory blob.
#[allow(unused)]
pub fn encrypt(&self, plaintext: &[u8]) -> crypto::EncryptedMemory {
self.0.as_key().encrypt(plaintext)
}
/// Decrypts the provided EncryptedMemory blob using the contained key, returning the plaintext.
/// If the decryption fails, that means the memory was tampered with, and the function panics.
#[allow(unused)]
pub fn decrypt(&self, encrypted: &crypto::EncryptedMemory) -> Result<Vec<u8>, DecryptionError> {
self.0.as_key().decrypt(encrypted)
}
}
/// A platform specific implementation of a key container that protects a single encryption key
/// from memory attacks.
#[allow(unused)]
trait SecureKeyContainer: Sync + Send {
/// Returns the key as a byte slice. This slice does not have additional memory protections applied.
fn as_key(&self) -> crypto::MemoryEncryptionKey;
/// Creates a new SecureKeyContainer from the provided key.
fn from_key(key: crypto::MemoryEncryptionKey) -> Self;
/// Returns true if this platform supports this secure key container implementation.
fn is_supported() -> bool;
}
#[allow(unused)]
enum CrossPlatformSecureKeyContainer {
#[cfg(target_os = "windows")]
Dpapi(dpapi::DpapiSecureKeyContainer),
#[cfg(target_os = "linux")]
Keyctl(keyctl::KeyctlSecureKeyContainer),
#[cfg(target_os = "linux")]
MemfdSecret(memfd_secret::MemfdSecretSecureKeyContainer),
Mlock(mlock::MlockSecureKeyContainer),
}
impl SecureKeyContainer for CrossPlatformSecureKeyContainer {
fn as_key(&self) -> crypto::MemoryEncryptionKey {
match self {
#[cfg(target_os = "windows")]
CrossPlatformSecureKeyContainer::Dpapi(c) => c.as_key(),
#[cfg(target_os = "linux")]
CrossPlatformSecureKeyContainer::Keyctl(c) => c.as_key(),
#[cfg(target_os = "linux")]
CrossPlatformSecureKeyContainer::MemfdSecret(c) => c.as_key(),
CrossPlatformSecureKeyContainer::Mlock(c) => c.as_key(),
}
}
fn from_key(key: crypto::MemoryEncryptionKey) -> Self {
if let Some(container) = get_env_forced_container() {
return container;
}
#[cfg(target_os = "windows")]
{
if dpapi::DpapiSecureKeyContainer::is_supported() {
info!("Using DPAPI for secure key storage");
return CrossPlatformSecureKeyContainer::Dpapi(
dpapi::DpapiSecureKeyContainer::from_key(key),
);
}
}
#[cfg(target_os = "linux")]
{
// Memfd_secret is slightly better in some cases of the kernel being compromised.
// Note that keyctl may sometimes not be available in e.g. snap. Memfd_secret is
// not available on kernels older than 6.5 while keyctl is supported since 2.6.
//
// Note: This may prevent the system from hibernating but not sleeping. Hibernate
// would write the memory to disk, exposing the keys. If this is an issue,
// the environment variable `SECURE_KEY_CONTAINER_BACKEND` can be used
// to force the use of keyctl or mlock.
if memfd_secret::MemfdSecretSecureKeyContainer::is_supported() {
info!("Using memfd_secret for secure key storage");
return CrossPlatformSecureKeyContainer::MemfdSecret(
memfd_secret::MemfdSecretSecureKeyContainer::from_key(key),
);
}
if keyctl::KeyctlSecureKeyContainer::is_supported() {
info!("Using keyctl for secure key storage");
return CrossPlatformSecureKeyContainer::Keyctl(
keyctl::KeyctlSecureKeyContainer::from_key(key),
);
}
}
// Falling back to mlock means that the key is accessible via memory dumping.
info!("Falling back to mlock for secure key storage");
CrossPlatformSecureKeyContainer::Mlock(mlock::MlockSecureKeyContainer::from_key(key))
}
fn is_supported() -> bool {
// Mlock is always supported as a fallback.
true
}
}
fn get_env_forced_container() -> Option<CrossPlatformSecureKeyContainer> {
let env_var = std::env::var("SECURE_KEY_CONTAINER_BACKEND");
match env_var.as_deref() {
#[cfg(target_os = "windows")]
Ok("dpapi") => {
info!("Forcing DPAPI secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Dpapi(
dpapi::DpapiSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
#[cfg(target_os = "linux")]
Ok("memfd_secret") => {
info!("Forcing memfd_secret secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::MemfdSecret(
memfd_secret::MemfdSecretSecureKeyContainer::from_key(
crypto::MemoryEncryptionKey::new(),
),
))
}
#[cfg(target_os = "linux")]
Ok("keyctl") => {
info!("Forcing keyctl secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Keyctl(
keyctl::KeyctlSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
Ok("mlock") => {
info!("Forcing mlock secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Mlock(
mlock::MlockSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
_ => {
info!(
"{} is not a valid secure key container backend, using automatic selection",
env_var.unwrap_or_default()
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
// Create 20 different keys
let original_keys: Vec<crypto::MemoryEncryptionKey> = (0..20)
.map(|_| crypto::MemoryEncryptionKey::new())
.collect();
// Store them in secure containers
let containers: Vec<CrossPlatformSecureKeyContainer> = original_keys
.iter()
.map(|key| {
let key_bytes: &[u8; crypto::KEY_SIZE] = key.as_ref().try_into().unwrap();
CrossPlatformSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::from(
key_bytes,
))
})
.collect();
// Read all keys back and validate they match the originals
for (i, (original_key, container)) in
original_keys.iter().zip(containers.iter()).enumerate()
{
let retrieved_key = container.as_key();
assert_eq!(
original_key.as_ref(),
retrieved_key.as_ref(),
"Key {} should match after storage and retrieval",
i
);
}
// Verify all keys are different from each other
for i in 0..original_keys.len() {
for j in (i + 1)..original_keys.len() {
assert_ne!(
original_keys[i].as_ref(),
original_keys[j].as_ref(),
"Keys {} and {} should be different",
i,
j
);
}
}
// Read keys back a second time to ensure consistency
for (i, (original_key, container)) in
original_keys.iter().zip(containers.iter()).enumerate()
{
let retrieved_key_again = container.as_key();
assert_eq!(
original_key.as_ref(),
retrieved_key_again.as_ref(),
"Key {} should still match on second retrieval",
i
);
}
}
}

View File

@@ -12,6 +12,8 @@ import {
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "set-pin.component.html",
imports: [

View File

@@ -20,6 +20,8 @@ import {
import { UserVerificationComponent } from "../app/components/user-verification.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-delete-account",
templateUrl: "delete-account.component.html",

View File

@@ -0,0 +1,91 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<ng-container bitDialogTitle>
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = (multiStepSubmit | async)[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
<div class="tw-flex tw-flex-col">
@let showBadge = firstTimeDialog();
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-28 tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
{{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }}
@if (!firstTimeDialog) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
}
</span>
</div>
</ng-template>
<ng-template #step1Title>
{{ "howToTurnOnAutoConfirm" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
{{ "openExtension" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</ng-template>

View File

@@ -0,0 +1,249 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
tap,
} from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "./policy-edit-dialog.component";
export type MultiStepSubmit = {
sideEffect: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
};
/**
* Custom policy dialog component for Auto-Confirm policy.
* Satisfies the PolicyDialogComponent interface structurally
* via its static open() function.
*/
@Component({
templateUrl: "auto-confirm-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected firstTimeDialog = signal(false);
protected currentStep = signal(0);
protected multiStepSubmit: Observable<MultiStepSubmit[]> = of([]);
protected autoConfirmEnabled$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
private submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
private submitPolicyTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step0Title");
private openExtensionTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step1Title");
override policyComponent: AutoConfirmPolicyEditComponent | undefined;
constructor(
@Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private policyService: PolicyService,
private router: Router,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);
this.firstTimeDialog.set(data.firstTimeDialog ?? false);
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
async ngAfterViewInit() {
await super.ngAfterViewInit();
if (this.policyComponent) {
this.saveDisabled$ = combineLatest([
this.autoConfirmEnabled$,
this.policyComponent.enabled.valueChanges.pipe(
startWith(this.policyComponent.enabled.value),
),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false),
tap((singleOrgPolicyEnabled) =>
this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
map((singleOrgPolicyEnabled) => [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
{
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
},
]),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();
}
await this.submitAutoConfirm();
}
/**
* Triggers policy submission for auto confirm.
* @returns boolean: true if multi-submit workflow should continue, false otherwise.
*/
private async submitAutoConfirm() {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
autoConfirmRequest,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!this.policyComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
private async submitSingleOrg(): Promise<void> {
const singleOrgRequest: PolicyRequest = {
type: PolicyType.SingleOrg,
enabled: true,
data: null,
};
await this.policyApiService.putPolicy(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
);
}
private async openBrowserExtension() {
await this.router.navigate(["/browser-extension-prompt"], {
queryParams: { url: "AutoConfirm" },
});
}
submit = async () => {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
await multiStepSubmit[this.currentStep()].sideEffect();
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
this.policyComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (
dialogService: DialogService,
config: DialogConfig<AutoConfirmPolicyDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
};
}

View File

@@ -8,8 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component";
/**
* Interface for policy dialog components.
* Any component that implements this interface can be used as a custom policy edit dialog.
*/
export interface PolicyDialogComponent {
open: (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => DialogRef<PolicyEditDialogResult>;
}
/**
* A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing.
@@ -37,9 +49,8 @@ export abstract class BasePolicyEditDefinition {
/**
* The dialog component that will be opened when editing this policy.
* This allows customizing the look and feel of each policy's dialog contents.
* If not specified, defaults to {@link PolicyEditDialogComponent}.
*/
editDialogComponent?: typeof PolicyEditDialogComponent;
editDialogComponent?: PolicyDialogComponent;
/**
* If true, the {@link description} will be reused in the policy edit modal. Set this to false if you

View File

@@ -1,17 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
lastValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import {
@@ -19,9 +20,11 @@ import {
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { safeProvider } from "@bitwarden/ui-common";
@@ -29,7 +32,7 @@ import { safeProvider } from "@bitwarden/ui-common";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
@@ -59,8 +62,18 @@ export class PoliciesComponent implements OnInit {
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private dialogService: DialogService,
private policyService: PolicyService,
protected configService: ConfigService,
) {}
) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
tap(async () => await this.load()),
takeUntilDestroyed(),
)
.subscribe();
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@@ -127,17 +140,13 @@ export class PoliciesComponent implements OnInit {
}
async edit(policy: BasePolicyEditDefinition) {
const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent;
const dialogRef = dialogComponent.open(this.dialogService, {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == "saved") {
await this.load();
}
}
}

View File

@@ -0,0 +1,59 @@
<ng-container [ngTemplateOutlet]="steps[step]()"></ng-container>
<ng-template #step0>
<p class="tw-mb-6">
{{ "autoConfirmPolicyEditDescription" | i18n }}
</p>
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
<a bitLink href="https://bitwarden.com/help/automatic-confirmation/" target="_blank">
{{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }}
<i class="bwi bwi-external-link bwi-fw"></i>
</a>
</li>
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
</li>
<li>
<span class="tw-font-bold">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}
</li>
</ul>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "autoConfirmCheckBoxLabel" | i18n }}</bit-label>
</ng-template>
<ng-template #step1>
<div class="tw-flex tw-justify-center tw-mb-6">
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
</div>
<ol>
<li>1. {{ "autoConfirmStep1" | i18n }}</li>
<li>
2. {{ "autoConfirmStep2a" | i18n }}
<strong>
{{ "autoConfirmStep2b" | i18n }}
</strong>
</li>
</ol>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";
description = "autoConfirmDescription";
type = PolicyType.AutoConfirm;
component = AutoConfirmPolicyEditComponent;
showDescription = false;
editDialogComponent = AutoConfirmPolicyDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.AutoConfirm)
.pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation));
}
}
@Component({
templateUrl: "auto-confirm-policy.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;
private policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private extensionButton: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected step: number = 0;
protected steps = [this.policyForm, this.extensionButton];
protected singleOrgEnabled$: BehaviorSubject<boolean> = new BehaviorSubject(false);
setSingleOrgEnabled(enabled: boolean) {
this.singleOrgEnabled$.next(enabled);
}
setStep(step: number) {
this.step = step;
}
}

View File

@@ -14,3 +14,4 @@ export {
vNextOrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicyComponent,
} from "./vnext-organization-data-ownership.component";
export { AutoConfirmPolicy } from "./auto-confirm-policy.component";

View File

@@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -64,13 +64,13 @@ export class PolicyEditDialogComponent implements AfterViewInit {
});
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private i18nService: I18nService,
protected accountService: AccountService,
protected policyApiService: PolicyApiServiceAbstraction,
protected i18nService: I18nService,
private cdr: ChangeDetectorRef,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<PolicyEditDialogResult>,
private toastService: ToastService,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
) {}

View File

@@ -1,5 +1,6 @@
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import {
AutoConfirmPolicy,
DesktopAutotypeDefaultSettingPolicy,
DisableSendPolicy,
MasterPasswordPolicy,
@@ -33,4 +34,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
new DesktopAutotypeDefaultSettingPolicy(),
new AutoConfirmPolicy(),
];

View File

@@ -12,6 +12,8 @@ import { SharedModule } from "../../../shared";
import { EmergencyAccessModule } from "../emergency-access.module";
import { EmergencyAccessService } from "../services/emergency-access.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [SharedModule, EmergencyAccessModule],
templateUrl: "accept-emergency.component.html",

View File

@@ -11,18 +11,24 @@ import { RouterService } from "../../../core/router.service";
import { deepLinkGuard } from "./deep-link.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class GuardedRouteTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class LockTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,

View File

@@ -16,6 +16,8 @@ import { BaseAcceptComponent } from "../../common/base.accept.component";
import { AcceptOrganizationInviteService } from "./accept-organization.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "accept-organization.component.html",
standalone: false,

View File

@@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-delete",
templateUrl: "recover-delete.component.html",

View File

@@ -16,6 +16,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-two-factor",
templateUrl: "recover-two-factor.component.html",

View File

@@ -19,6 +19,8 @@ import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"
import { ProfileComponent } from "./profile.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account.component.html",
imports: [

View File

@@ -32,6 +32,8 @@ type ChangeAvatarDialogData = {
profile: ProfileResponse;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "change-avatar-dialog.component.html",
encapsulation: ViewEncapsulation.None,
@@ -40,6 +42,8 @@ type ChangeAvatarDialogData = {
export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
profile: ProfileResponse;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
loading = false;

View File

@@ -17,6 +17,8 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-change-email",
templateUrl: "change-email.component.html",

View File

@@ -9,6 +9,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
/**
* Component for the Danger Zone section of the Account/Organization Settings page.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-danger-zone",
templateUrl: "danger-zone.component.html",

View File

@@ -12,6 +12,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deauthorize-sessions.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -12,6 +12,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-account-dialog.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -23,6 +23,8 @@ import { AccountFingerprintComponent } from "../../../shared/components/account-
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-profile",
templateUrl: "profile.component.html",

View File

@@ -5,6 +5,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AvatarModule } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "selectable-avatar",
template: `<span
@@ -30,12 +32,26 @@ import { AvatarModule } from "@bitwarden/components";
imports: [NgClass, AvatarModule],
})
export class SelectableAvatarComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() id: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() text: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() color: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() border = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selected = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() select = new EventEmitter<string>();
onFire() {

View File

@@ -27,6 +27,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./set-account-verify-devices-dialog.component.html",
imports: [

View File

@@ -25,6 +25,8 @@ type EmergencyAccessConfirmDialogData = {
/** user public key */
publicKey: Uint8Array;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-confirm.component.html",
imports: [SharedModule],

View File

@@ -35,6 +35,8 @@ export enum EmergencyAccessAddEditDialogResult {
Canceled = "canceled",
Deleted = "deleted",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-add-edit.component.html",
imports: [SharedModule, PremiumBadgeComponent],

View File

@@ -42,6 +42,8 @@ import {
EmergencyAccessTakeoverDialogResultType,
} from "./takeover/emergency-access-takeover-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access.component.html",
imports: [SharedModule, HeaderModule, PremiumBadgeComponent],

View File

@@ -48,6 +48,8 @@ export type EmergencyAccessTakeoverDialogResultType =
*
* @link https://bitwarden.com/help/emergency-access/
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-emergency-access-takeover-dialog",
templateUrl: "./emergency-access-takeover-dialog.component.html",
@@ -61,6 +63,8 @@ export type EmergencyAccessTakeoverDialogResultType =
],
})
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;

View File

@@ -14,6 +14,8 @@ import { EmergencyAccessService } from "../../../emergency-access";
import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-view.component.html",
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],

View File

@@ -35,6 +35,8 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-emergency-view-dialog",
templateUrl: "emergency-view-dialog.component.html",

View File

@@ -23,6 +23,8 @@ export type ApiKeyDialogData = {
apiKeyWarning: string;
apiKeyDescription: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "api-key.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -10,6 +10,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-password-settings",
templateUrl: "password-settings.component.html",

View File

@@ -13,6 +13,8 @@ import { SharedModule } from "../../../shared";
import { ApiKeyComponent } from "./api-key.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security-keys.component.html",
imports: [SharedModule, ChangeKdfModule],

View File

@@ -5,6 +5,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security.component.html",
imports: [SharedModule, HeaderModule],

View File

@@ -15,6 +15,8 @@ import {
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-recovery",
templateUrl: "two-factor-recovery.component.html",

View File

@@ -53,6 +53,8 @@ declare global {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-authenticator",
templateUrl: "two-factor-setup-authenticator.component.html",
@@ -76,6 +78,8 @@ export class TwoFactorSetupAuthenticatorComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit, OnDestroy
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus = new EventEmitter<boolean>();
type = TwoFactorProviderType.Authenticator;
key: string;

View File

@@ -30,6 +30,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-duo",
templateUrl: "two-factor-setup-duo.component.html",
@@ -51,6 +53,8 @@ export class TwoFactorSetupDuoComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Duo;

View File

@@ -33,6 +33,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-email",
templateUrl: "two-factor-setup-email.component.html",
@@ -54,6 +56,8 @@ export class TwoFactorSetupEmailComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Email;
sentEmail: string = "";

View File

@@ -17,6 +17,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
*/
@Directive({})
export abstract class TwoFactorSetupMethodBaseComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUpdated = new EventEmitter<boolean>();
type: TwoFactorProviderType | undefined;

View File

@@ -43,6 +43,8 @@ interface Key {
removePromise: Promise<TwoFactorWebAuthnResponse> | null;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-webauthn",
templateUrl: "two-factor-setup-webauthn.component.html",

View File

@@ -44,6 +44,8 @@ interface Key {
existingKey: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-yubikey",
templateUrl: "two-factor-setup-yubikey.component.html",

View File

@@ -45,6 +45,8 @@ import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.com
import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component";
import { TwoFactorVerifyComponent } from "./two-factor-verify.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup",
templateUrl: "two-factor-setup.component.html",

View File

@@ -28,6 +28,8 @@ type TwoFactorVerifyDialogData = {
organizationId: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-verify",
templateUrl: "two-factor-verify.component.html",
@@ -43,6 +45,8 @@ type TwoFactorVerifyDialogData = {
export class TwoFactorVerifyComponent {
type: TwoFactorProviderType;
organizationId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAuthed = new EventEmitter<AuthResponse<TwoFactorResponse>>();
formPromise: Promise<TwoFactorResponse> | undefined;

View File

@@ -16,6 +16,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email",
templateUrl: "verify-email.component.html",
@@ -24,7 +26,11 @@ import {
export class VerifyEmailComponent {
actionPromise: Promise<unknown>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onVerified = new EventEmitter<boolean>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDismiss = new EventEmitter<void>();
constructor(

View File

@@ -32,6 +32,8 @@ type Step =
| "credentialCreationFailed"
| "credentialNaming";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "create-credential-dialog.component.html",
standalone: false,

View File

@@ -24,6 +24,8 @@ export interface DeleteCredentialDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-credential-dialog.component.html",
standalone: false,

View File

@@ -21,6 +21,8 @@ export interface EnableEncryptionDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "enable-encryption-dialog.component.html",
standalone: false,

View File

@@ -17,6 +17,8 @@ import { openCreateCredentialDialog } from "./create-credential-dialog/create-cr
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-webauthn-login-settings",
templateUrl: "webauthn-login-settings.component.html",

View File

@@ -21,6 +21,8 @@ import {
/**
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "user-verification-prompt.component.html",
standalone: false,

View File

@@ -8,6 +8,8 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead.
* Each client specific component should eventually be converted over to use one of these new components.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-user-verification",
templateUrl: "user-verification.component.html",

View File

@@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",

View File

@@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete",
templateUrl: "verify-recover-delete.component.html",

View File

@@ -1,21 +1,29 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
DialogService,
ToastService,
SectionComponent,
BadgeModule,
TypographyModule,
DialogService,
LinkModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -69,14 +77,14 @@ export class PremiumVNextComponent {
constructor(
private accountService: AccountService,
private i18nService: I18nService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -107,6 +115,23 @@ export class PremiumVNextComponent {
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
// redirect to user subscription page if they already have premium personally
// redirect to individual vault if they already have premium from an org
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
if (hasPremiumFromOrg) {
return from(this.navigateToIndividualVault());
}
return of(true);
}),
)
.subscribe();
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
@@ -141,6 +166,11 @@ export class PremiumVNextComponent {
);
}
private navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);

View File

@@ -1,12 +1,14 @@
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
<bit-dialog>
<span bitDialogTitle>
{{ "changeKdf" | i18n }}
{{ "updateYourEncryptionSettings" | i18n }}
</span>
<span bitDialogContent>
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<bit-form-field>
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
}
<bit-form-field disableMargin>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
<button
@@ -18,12 +20,12 @@
></button>
<bit-hint>
{{ "confirmIdentity" | i18n }}
</bit-hint></bit-form-field
>
</bit-hint>
</bit-form-field>
</span>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit" bitFormButton>
<span>{{ "changeKdf" | i18n }}</span>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}

View File

@@ -0,0 +1,243 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormControl } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KdfType, PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
import { SharedModule } from "../../shared";
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
describe("ChangeKdfConfirmationComponent", () => {
let component: ChangeKdfConfirmationComponent;
let fixture: ComponentFixture<ChangeKdfConfirmationComponent>;
// Mock Services
let mockI18nService: MockProxy<I18nService>;
let mockMessagingService: MockProxy<MessagingService>;
let mockToastService: MockProxy<ToastService>;
let mockDialogRef: MockProxy<DialogRef<ChangeKdfConfirmationComponent>>;
let mockConfigService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
let mockChangeKdfService: MockProxy<ChangeKdfService>;
const mockUserId = "user-id" as UserId;
const mockEmail = "email";
const mockMasterPassword = "master-password";
const mockDialogData = jest.fn();
const kdfConfig = new PBKDF2KdfConfig(600_001);
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockMessagingService = mock<MessagingService>();
mockToastService = mock<ToastService>();
mockDialogRef = mock<DialogRef<ChangeKdfConfirmationComponent>>();
mockConfigService = mock<ConfigService>();
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
mockChangeKdfService = mock<ChangeKdfService>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
// Mock config service feature flag
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockDialogData.mockReturnValue({
kdf: KdfType.PBKDF2_SHA256,
kdfConfig,
});
TestBed.configureTestingModule({
declarations: [ChangeKdfConfirmationComponent],
imports: [SharedModule],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: MessagingService, useValue: mockMessagingService },
{ provide: AccountService, useValue: accountService },
{ provide: ToastService, useValue: mockToastService },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ChangeKdfService, useValue: mockChangeKdfService },
{
provide: DIALOG_DATA,
useFactory: mockDialogData,
},
],
});
});
describe("Component Initialization", () => {
it("should create component with PBKDF2 config", () => {
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component).toBeTruthy();
expect(component.kdfConfig).toBeInstanceOf(PBKDF2KdfConfig);
expect(component.kdfConfig.iterations).toBe(600_001);
});
it("should create component with Argon2id config", () => {
mockDialogData.mockReturnValue({
kdf: KdfType.Argon2id,
kdfConfig: new Argon2KdfConfig(4, 65, 5),
});
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component).toBeTruthy();
expect(component.kdfConfig).toBeInstanceOf(Argon2KdfConfig);
const kdfConfig = component.kdfConfig as Argon2KdfConfig;
expect(kdfConfig.iterations).toBe(4);
expect(kdfConfig.memory).toBe(65);
expect(kdfConfig.parallelism).toBe(5);
});
it("should initialize form with required master password field", () => {
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
expect(component.form.controls.masterPassword).toBeInstanceOf(FormControl);
expect(component.form.controls.masterPassword.value).toEqual(null);
expect(component.form.controls.masterPassword.hasError("required")).toBe(true);
});
});
describe("Form Validation", () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
component = fixture.componentInstance;
});
it("should be invalid when master password is empty", () => {
component.form.controls.masterPassword.setValue("");
expect(component.form.invalid).toBe(true);
});
it("should be valid when master password is provided", () => {
component.form.controls.masterPassword.setValue(mockMasterPassword);
expect(component.form.valid).toBe(true);
});
});
describe("submit method", () => {
describe("should not update kdf and not show success toast", () => {
beforeEach(() => {
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
});
it("when form is invalid", async () => {
// Arrange
component.form.controls.masterPassword.setValue("");
expect(component.form.invalid).toBe(true);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
it("when no active account", async () => {
accountService.activeAccount$ = of(null);
await expect(component.submit()).rejects.toThrow("Null or undefined account");
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
it("when kdf is invalid", async () => {
// Arrange
component.kdfConfig = new PBKDF2KdfConfig(1);
// Act
await expect(component.submit()).rejects.toThrow();
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
});
});
describe("should update kdf and show success toast", () => {
it("should set loading to true during submission", async () => {
// Arrange
let loadingDuringExecution = false;
mockChangeKdfService.updateUserKdfParams.mockImplementation(async () => {
loadingDuringExecution = component.loading;
});
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
expect(loadingDuringExecution).toBe(true);
expect(component.loading).toBe(false);
});
it("doesn't logout and closes the dialog when feature flag is enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
kdfConfig,
mockUserId,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "encKeySettingsChanged-used-i18n",
});
expect(mockDialogRef.close).toHaveBeenCalled();
expect(mockMessagingService.send).not.toHaveBeenCalled();
});
it("sends a logout and displays a log back in toast when feature flag is disabled", async () => {
// Arrange
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
const component = fixture.componentInstance;
component.form.controls.masterPassword.setValue(mockMasterPassword);
// Act
await component.submit();
// Assert
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
kdfConfig,
mockUserId,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: "encKeySettingsChanged-used-i18n",
message: "logBackIn-used-i18n",
});
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,15 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -23,12 +23,13 @@ export class ChangeKdfConfirmationComponent {
kdfConfig: KdfConfig;
form = new FormGroup({
masterPassword: new FormControl(null, Validators.required),
masterPassword: new FormControl<string | null>(null, Validators.required),
});
showPassword = false;
masterPassword: string;
loading = false;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private i18nService: I18nService,
private messagingService: MessagingService,
@@ -36,9 +37,13 @@ export class ChangeKdfConfirmationComponent {
private accountService: AccountService,
private toastService: ToastService,
private changeKdfService: ChangeKdfService,
private dialogRef: DialogRef<ChangeKdfConfirmationComponent>,
configService: ConfigService,
) {
this.kdfConfig = params.kdfConfig;
this.masterPassword = null;
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
submit = async () => {
@@ -46,24 +51,32 @@ export class ChangeKdfConfirmationComponent {
return;
}
this.loading = true;
await this.makeKeyAndSaveAsync();
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("encKeySettingsChanged"),
message: this.i18nService.t("logBackIn"),
});
this.messagingService.send("logout");
await this.makeKeyAndSave();
if (await firstValueFrom(this.noLogoutOnKdfChangeFeatureFlag$)) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("encKeySettingsChanged"),
});
this.dialogRef.close();
} else {
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("encKeySettingsChanged"),
message: this.i18nService.t("logBackIn"),
});
this.messagingService.send("logout");
}
this.loading = false;
};
private async makeKeyAndSaveAsync() {
const masterPassword = this.form.value.masterPassword;
private async makeKeyAndSave() {
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const masterPassword = this.form.value.masterPassword!;
// Ensure the KDF config is valid.
this.kdfConfig.validateKdfConfigForSetting();
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.changeKdfService.updateUserKdfParams(
masterPassword,
this.kdfConfig,

View File

@@ -1,31 +1,30 @@
<h2 bitTypography="h2">{{ "encKeySettings" | i18n }}</h2>
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "higherKDFIterations" | i18n }}
<h2 bitTypography="h2" class="tw-mt-6">
{{ "encKeySettings" | i18n }}
</h2>
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
}
<p bitTypography="body1" class="tw-mt-4">
{{ "encryptionKeySettingsHowShouldWeEncryptYourData" | i18n }}
</p>
<p bitTypography="body1">
{{
"kdfToHighWarningIncreaseInIncrements"
| i18n: (isPBKDF2(kdfConfig) ? ("incrementsOf100,000" | i18n) : ("smallIncrements" | i18n))
}}
{{ "encryptionKeySettingsIncreaseImproveSecurity" | i18n }}
</p>
<form [formGroup]="formGroup" autocomplete="off">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-grid tw-grid-cols-12 tw-gap-x-4">
<div class="tw-col-span-6">
<bit-form-field>
<bit-label
>{{ "kdfAlgorithm" | i18n }}
<a
class="tw-ml-auto"
<bit-label>
{{ "algorithm" | i18n }}
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
[bitPopoverTriggerFor]="algorithmPopover"
appA11yTitle="{{ 'encryptionKeySettingsAlgorithmPopoverTitle' | i18n }}"
bitLink
href="https://bitwarden.com/help/kdf-algorithms"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</button>
</bit-label>
<bit-select formControlName="kdf">
<bit-option
@@ -35,33 +34,12 @@
></bit-option>
</bit-select>
</bit-form-field>
<bit-form-field formGroupName="kdfConfig" *ngIf="isArgon2(kdfConfig)">
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
<input
bitInput
formControlName="memory"
type="number"
[min]="ARGON2_MEMORY.min"
[max]="ARGON2_MEMORY.max"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<div class="tw-mb-0">
<bit-form-field formGroupName="kdfConfig" *ngIf="isPBKDF2(kdfConfig)">
@if (isPBKDF2(kdfConfig)) {
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
<a
bitLink
class="tw-ml-auto"
href="https://bitwarden.com/help/what-encryption-is-used/#changing-kdf-iterations"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutKDFIterations' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<input
bitInput
@@ -72,34 +50,49 @@
/>
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
</bit-form-field>
<ng-container *ngIf="isArgon2(kdfConfig)">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="iterations"
[min]="ARGON2_ITERATIONS.min"
[max]="ARGON2_ITERATIONS.max"
/>
</bit-form-field>
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfParallelism" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="parallelism"
[min]="ARGON2_PARALLELISM.min"
[max]="ARGON2_PARALLELISM.max"
/>
</bit-form-field>
</ng-container>
</div>
} @else if (isArgon2(kdfConfig)) {
<bit-form-field formGroupName="kdfConfig">
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
<input
bitInput
formControlName="memory"
type="number"
[min]="ARGON2_MEMORY.min"
[max]="ARGON2_MEMORY.max"
/>
</bit-form-field>
}
</div>
@if (isArgon2(kdfConfig)) {
<div class="tw-col-span-6">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfIterations" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="iterations"
[min]="ARGON2_ITERATIONS.min"
[max]="ARGON2_ITERATIONS.max"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfParallelism" | i18n }}
</bit-label>
<input
bitInput
type="number"
formControlName="parallelism"
[min]="ARGON2_PARALLELISM.min"
[max]="ARGON2_PARALLELISM.max"
/>
</bit-form-field>
</div>
}
</div>
<button
(click)="openConfirmationModal()"
@@ -107,7 +100,27 @@
buttonType="primary"
bitButton
bitFormButton
class="tw-mt-2"
>
{{ "changeKdf" | i18n }}
{{ "updateEncryptionSettings" | i18n }}
</button>
</form>
<bit-popover [title]="'encryptionKeySettingsAlgorithmPopoverTitle' | i18n" #algorithmPopover>
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
<li class="tw-mb-2">{{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}</li>
<li>{{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}</li>
</ul>
<div class="tw-mt-4 tw-mb-1">
<a
href="https://bitwarden.com/help/kdf-algorithms/"
bitLink
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-popover>

View File

@@ -0,0 +1,365 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, FormControl } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components";
import {
KdfConfigService,
Argon2KdfConfig,
PBKDF2KdfConfig,
KdfType,
} from "@bitwarden/key-management";
import { SharedModule } from "../../shared";
import { ChangeKdfComponent } from "./change-kdf.component";
describe("ChangeKdfComponent", () => {
let component: ChangeKdfComponent;
let fixture: ComponentFixture<ChangeKdfComponent>;
// Mock Services
let mockDialogService: MockProxy<DialogService>;
let mockKdfConfigService: MockProxy<KdfConfigService>;
let mockConfigService: MockProxy<ConfigService>;
let mockI18nService: MockProxy<I18nService>;
let accountService: FakeAccountService;
let formBuilder: FormBuilder;
const mockUserId = "user-id" as UserId;
// Helper functions for validation testing
function expectPBKDF2Validation(
iterationsControl: FormControl<number | null>,
memoryControl: FormControl<number | null>,
parallelismControl: FormControl<number | null>,
) {
// Assert current validators state
expect(iterationsControl.hasError("required")).toBe(false);
expect(iterationsControl.hasError("min")).toBe(false);
expect(iterationsControl.hasError("max")).toBe(false);
expect(memoryControl.validator).toBeNull();
expect(parallelismControl.validator).toBeNull();
// Test validation boundaries
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1);
expect(iterationsControl.hasError("min")).toBe(true);
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1);
expect(iterationsControl.hasError("max")).toBe(true);
}
function expectArgon2Validation(
iterationsControl: FormControl<number | null>,
memoryControl: FormControl<number | null>,
parallelismControl: FormControl<number | null>,
) {
// Assert current validators state
expect(iterationsControl.hasError("required")).toBe(false);
expect(memoryControl.hasError("required")).toBe(false);
expect(parallelismControl.hasError("required")).toBe(false);
// Test validation boundaries - min values
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1);
expect(iterationsControl.hasError("min")).toBe(true);
memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1);
expect(memoryControl.hasError("min")).toBe(true);
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1);
expect(parallelismControl.hasError("min")).toBe(true);
// Test validation boundaries - max values
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1);
expect(iterationsControl.hasError("max")).toBe(true);
memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1);
expect(memoryControl.hasError("max")).toBe(true);
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1);
expect(parallelismControl.hasError("max")).toBe(true);
}
beforeEach(() => {
mockDialogService = mock<DialogService>();
mockKdfConfigService = mock<KdfConfigService>();
mockConfigService = mock<ConfigService>();
mockI18nService = mock<I18nService>();
accountService = mockAccountServiceWith(mockUserId);
formBuilder = new FormBuilder();
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
TestBed.configureTestingModule({
declarations: [ChangeKdfComponent],
imports: [SharedModule, PopoverModule, CalloutModule],
providers: [
{ provide: DialogService, useValue: mockDialogService },
{ provide: KdfConfigService, useValue: mockKdfConfigService },
{ provide: AccountService, useValue: accountService },
{ provide: FormBuilder, useValue: formBuilder },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: I18nService, useValue: mockI18nService },
],
});
});
describe("Component Initialization", () => {
describe("given PBKDF2 configuration", () => {
it("should initialize form with PBKDF2 values and validators when component loads", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Extract form controls
const formGroup = component["formGroup"];
// Assert form values
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000);
expect(kdfConfigFormGroup.controls.memory.value).toBeNull();
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull();
expect(component.kdfConfig).toEqual(mockPBKDF2Config);
// Assert validators
expectPBKDF2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
describe("given Argon2id configuration", () => {
it("should initialize form with Argon2id values and validators when component loads", async () => {
// Arrange
const mockArgon2Config = new Argon2KdfConfig(3, 64, 4);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Extract form controls
const formGroup = component["formGroup"];
// Assert form values
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3);
expect(kdfConfigFormGroup.controls.memory.value).toBe(64);
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4);
expect(component.kdfConfig).toEqual(mockArgon2Config);
// Assert validators
expectArgon2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
it.each([
[true, false],
[false, true],
])(
"should show log out banner = %s when feature flag observable is %s",
async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag));
// Act
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
fixture.detectChanges();
// Assert
const calloutElement = fixture.debugElement.query((el) =>
el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"),
);
if (showLogOutBanner) {
expect(calloutElement).not.toBeNull();
expect(calloutElement.nativeElement.textContent).toContain(
"kdfSettingsChangeLogoutWarning-used-i18n",
);
} else {
expect(calloutElement).toBeNull();
}
},
);
});
describe("KDF Type Switching", () => {
describe("switching from PBKDF2 to Argon2id", () => {
beforeEach(async () => {
// Setup component with initial PBKDF2 configuration
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
});
it("should update form structure and default values when KDF type changes to Argon2id", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type to Argon2id
formGroup.controls.kdf.setValue(KdfType.Argon2id);
// Assert form values update to Argon2id defaults
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default
expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default
});
it("should update validators when KDF type changes to Argon2id", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type to Argon2id
formGroup.controls.kdf.setValue(KdfType.Argon2id);
// Assert validators update to Argon2id validation rules
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expectArgon2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
describe("switching from Argon2id to PBKDF2", () => {
beforeEach(async () => {
// Setup component with initial Argon2id configuration
const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
});
it("should update form structure and default values when KDF type changes to PBKDF2", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type back to PBKDF2
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
// Assert form values update to PBKDF2 defaults
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default
expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism
});
it("should update validators when KDF type changes to PBKDF2", () => {
// Arrange
const formGroup = component["formGroup"];
// Act - change KDF type back to PBKDF2
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
// Assert validators update to PBKDF2 validation rules
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
expectPBKDF2Validation(
kdfConfigFormGroup.controls.iterations,
kdfConfigFormGroup.controls.memory,
kdfConfigFormGroup.controls.parallelism,
);
});
});
});
describe("openConfirmationModal", () => {
describe("when form is valid", () => {
it("should open confirmation modal with PBKDF2 config when form is submitted", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: expect.objectContaining({
kdfConfig: mockPBKDF2Config,
}),
}),
);
});
it("should open confirmation modal with Argon2id config when form is submitted", async () => {
// Arrange
const mockArgon2Config = new Argon2KdfConfig(4, 65, 5);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: expect.objectContaining({
kdfConfig: mockArgon2Config,
}),
}),
);
});
it("should not open modal when form is invalid", async () => {
// Arrange
const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1);
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
fixture = TestBed.createComponent(ChangeKdfComponent);
component = fixture.componentInstance;
await component.ngOnInit();
// Act
await component.openConfirmationModal();
// Assert
expect(mockDialogService.open).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,11 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
KdfConfigService,
@@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected formGroup = this.formBuilder.group({
kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]),
kdf: new FormControl<KdfType>(KdfType.PBKDF2_SHA256, [Validators.required]),
kdfConfig: this.formBuilder.group({
iterations: [this.kdfConfig.iterations],
memory: [null as number],
parallelism: [null as number],
iterations: new FormControl<number | null>(null),
memory: new FormControl<number | null>(null),
parallelism: new FormControl<number | null>(null),
}),
});
@@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private dialogService: DialogService,
private kdfConfigService: KdfConfigService,
private accountService: AccountService,
private formBuilder: FormBuilder,
configService: ConfigService,
) {
this.kdfOptions = [
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
{ name: "Argon2id", value: KdfType.Argon2id },
];
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType);
this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType);
this.setFormControlValues(this.kdfConfig);
this.setFormValidators(this.kdfConfig.kdfType);
this.formGroup
.get("kdf")
.valueChanges.pipe(takeUntil(this.destroy$))
this.formGroup.controls.kdf.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((newValue) => {
this.updateKdfConfig(newValue);
this.updateKdfConfig(newValue!);
});
}
private updateKdfConfig(newValue: KdfType) {
let config: KdfConfig;
const validators: { [key: string]: ValidatorFn[] } = {
iterations: [],
memory: [],
parallelism: [],
};
switch (newValue) {
case KdfType.PBKDF2_SHA256:
config = new PBKDF2KdfConfig();
validators.iterations = [
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
];
break;
case KdfType.Argon2id:
config = new Argon2KdfConfig();
validators.iterations = [
Validators.required,
Validators.min(Argon2KdfConfig.ITERATIONS.min),
Validators.max(Argon2KdfConfig.ITERATIONS.max),
];
validators.memory = [
Validators.required,
Validators.min(Argon2KdfConfig.MEMORY.min),
Validators.max(Argon2KdfConfig.MEMORY.max),
];
validators.parallelism = [
Validators.required,
Validators.min(Argon2KdfConfig.PARALLELISM.min),
Validators.max(Argon2KdfConfig.PARALLELISM.max),
];
break;
default:
throw new Error("Unknown KDF type.");
}
this.kdfConfig = config;
this.setFormValidators(validators);
this.setFormValidators(newValue);
this.setFormControlValues(this.kdfConfig);
}
private setFormValidators(validators: { [key: string]: ValidatorFn[] }) {
this.setValidators("kdfConfig.iterations", validators.iterations);
this.setValidators("kdfConfig.memory", validators.memory);
this.setValidators("kdfConfig.parallelism", validators.parallelism);
}
private setValidators(controlName: string, validators: ValidatorFn[]) {
const control = this.formGroup.get(controlName);
if (control) {
control.setValidators(validators);
control.updateValueAndValidity();
private setFormValidators(kdfType: KdfType) {
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
switch (kdfType) {
case KdfType.PBKDF2_SHA256:
kdfConfigFormGroup.controls.iterations.setValidators([
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
]);
kdfConfigFormGroup.controls.memory.setValidators([]);
kdfConfigFormGroup.controls.parallelism.setValidators([]);
break;
case KdfType.Argon2id:
kdfConfigFormGroup.controls.iterations.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.ITERATIONS.min),
Validators.max(Argon2KdfConfig.ITERATIONS.max),
]);
kdfConfigFormGroup.controls.memory.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.MEMORY.min),
Validators.max(Argon2KdfConfig.MEMORY.max),
]);
kdfConfigFormGroup.controls.parallelism.setValidators([
Validators.required,
Validators.min(Argon2KdfConfig.PARALLELISM.min),
Validators.max(Argon2KdfConfig.PARALLELISM.max),
]);
break;
default:
throw new Error("Unknown KDF type.");
}
kdfConfigFormGroup.controls.iterations.updateValueAndValidity();
kdfConfigFormGroup.controls.memory.updateValueAndValidity();
kdfConfigFormGroup.controls.parallelism.updateValueAndValidity();
}
private setFormControlValues(kdfConfig: KdfConfig) {
this.formGroup.get("kdfConfig").reset();
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
kdfConfigFormGroup.reset();
if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
} else if (kdfConfig.kdfType === KdfType.Argon2id) {
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory);
this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism);
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory);
kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism);
}
}
@@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
} else if (this.kdfConfig.kdfType === KdfType.Argon2id) {
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value;
this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value;
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!;
this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!;
}
this.dialogService.open(ChangeKdfConfirmationComponent, {
data: {

View File

@@ -1,13 +1,15 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PopoverModule } from "@bitwarden/components";
import { SharedModule } from "../../shared";
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
import { ChangeKdfComponent } from "./change-kdf.component";
@NgModule({
imports: [CommonModule, SharedModule],
imports: [CommonModule, SharedModule, PopoverModule],
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
})

View File

@@ -4,13 +4,14 @@
<p bitTypography="body1" class="tw-mb-0 tw-mt-2">{{ "openingExtension" | i18n }}</p>
</ng-container>
@let page = extensionPage$ | async;
<ng-container *ngIf="pageState === BrowserPromptState.Error">
<p bitTypography="body1" class="tw-mb-4 tw-text-xl">{{ "openingExtensionError" | i18n }}</p>
<button
bitButton
buttonType="primary"
type="button"
(click)="openExtension()"
(click)="openExtension(page)"
id="bw-extension-prompt-button"
>
{{ "openExtension" | i18n }}
@@ -21,7 +22,7 @@
<ng-container *ngIf="pageState === BrowserPromptState.Success">
<i class="bwi tw-text-2xl bwi-check-circle tw-text-success-700" aria-hidden="true"></i>
<p bitTypography="body1" class="tw-mb-4 tw-text-xl">
{{ "openedExtensionViewAtRiskPasswords" | i18n }}
{{ pageToI18nKeyMap[page] | i18n }}
</p>
</ng-container>

View File

@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { BehaviorSubject } from "rxjs";
import { ActivatedRoute } from "@angular/router";
import { BehaviorSubject, of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -16,6 +17,7 @@ describe("BrowserExtensionPromptComponent", () => {
let component: BrowserExtensionPromptComponent;
const start = jest.fn();
const openExtension = jest.fn();
const registerPopupUrl = jest.fn();
const pageState$ = new BehaviorSubject<BrowserPromptState>(BrowserPromptState.Loading);
const setAttribute = jest.fn();
const getAttribute = jest.fn().mockReturnValue("width=1010");
@@ -23,6 +25,7 @@ describe("BrowserExtensionPromptComponent", () => {
beforeEach(async () => {
start.mockClear();
openExtension.mockClear();
registerPopupUrl.mockClear();
setAttribute.mockClear();
getAttribute.mockClear();
@@ -41,12 +44,20 @@ describe("BrowserExtensionPromptComponent", () => {
providers: [
{
provide: BrowserExtensionPromptService,
useValue: { start, openExtension, pageState$ },
useValue: { start, openExtension, registerPopupUrl, pageState$ },
},
{
provide: I18nService,
useValue: { t: (key: string) => key },
},
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of({
get: (key: string) => null,
}),
},
},
],
}).compileComponents();
@@ -92,7 +103,7 @@ describe("BrowserExtensionPromptComponent", () => {
button.click();
expect(openExtension).toHaveBeenCalledTimes(1);
expect(openExtension).toHaveBeenCalledWith(true);
expect(openExtension).toHaveBeenCalledWith("openAtRiskPasswords", true);
});
});

View File

@@ -1,6 +1,10 @@
import { CommonModule, DOCUMENT } from "@angular/common";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { map, Observable, of, tap } from "rxjs";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { ButtonComponent, IconModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -16,6 +20,8 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
})
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
protected VaultMessages = VaultMessages;
/** Current state of the prompt page */
protected pageState$ = this.browserExtensionPromptService.pageState$;
@@ -25,14 +31,33 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
/** Content of the meta[name="viewport"] element */
private viewportContent: string | null = null;
/** Map of extension page identifiers to their i18n keys */
protected readonly pageToI18nKeyMap: Record<string, string> = {
[VaultMessages.OpenAtRiskPasswords]: "openedExtensionViewAtRiskPasswords",
AutoConfirm: "autoConfirmExtensionOpened",
} as const;
protected extensionPage$: Observable<string> = of(VaultMessages.OpenAtRiskPasswords);
constructor(
private browserExtensionPromptService: BrowserExtensionPromptService,
private route: ActivatedRoute,
@Inject(DOCUMENT) private document: Document,
) {}
) {
this.extensionPage$ = this.route.queryParamMap.pipe(
map((params) => params.get("url") ?? VaultMessages.OpenAtRiskPasswords),
);
this.extensionPage$
.pipe(
tap((url) => this.browserExtensionPromptService.registerPopupUrl(url)),
takeUntilDestroyed(),
)
.subscribe();
}
ngOnInit(): void {
this.browserExtensionPromptService.start();
// It is not be uncommon for users to hit this page from a mobile device.
// There are global styles and the viewport meta tag that set a min-width
// for the page which cause it to render poorly. Remove them here.
@@ -57,7 +82,7 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
}
}
openExtension(): void {
this.browserExtensionPromptService.openExtension(true);
async openExtension(command: string) {
await this.browserExtensionPromptService.openExtension(command, true);
}
}

View File

@@ -1,4 +1,5 @@
import { TestBed } from "@angular/core/testing";
import { firstValueFrom, Observable } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -9,11 +10,13 @@ import {
BrowserExtensionPromptService,
BrowserPromptState,
} from "./browser-extension-prompt.service";
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
describe("BrowserExtensionPromptService", () => {
let service: BrowserExtensionPromptService;
const setAnonLayoutWrapperData = jest.fn();
const isFirefox = jest.fn().mockReturnValue(false);
const openExtensionMock = jest.fn().mockResolvedValue(undefined);
const postMessage = jest.fn();
window.postMessage = postMessage;
@@ -21,12 +24,14 @@ describe("BrowserExtensionPromptService", () => {
setAnonLayoutWrapperData.mockClear();
postMessage.mockClear();
isFirefox.mockClear();
openExtensionMock.mockClear();
TestBed.configureTestingModule({
providers: [
BrowserExtensionPromptService,
{ provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } },
{ provide: PlatformUtilsService, useValue: { isFirefox } },
{ provide: WebBrowserInteractionService, useValue: { openExtension: openExtensionMock } },
],
});
jest.useFakeTimers();
@@ -45,38 +50,42 @@ describe("BrowserExtensionPromptService", () => {
});
});
describe("start", () => {
describe("registerPopupUrl", () => {
it("posts message to check for extension", () => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.checkBwInstalled,
});
});
it("sets timeout for error state", () => {
service.start();
expect(service["extensionCheckTimeout"]).not.toBeNull();
});
it("attempts to open the extension when installed", () => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
window.dispatchEvent(
new MessageEvent("message", { data: { command: VaultMessages.HasBwInstalled } }),
);
expect(window.postMessage).toHaveBeenCalledTimes(2);
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.checkBwInstalled,
});
expect(window.postMessage).toHaveBeenCalledWith({
command: VaultMessages.OpenAtRiskPasswords,
});
});
});
describe("success state", () => {
beforeEach(() => {
describe("start", () => {
it("sets timeout for error state", () => {
service.start();
expect(service["extensionCheckTimeout"]).not.toBeNull();
});
});
describe("success registerPopupUrl", () => {
beforeEach(() => {
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
window.dispatchEvent(
new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }),
@@ -155,7 +164,7 @@ describe("BrowserExtensionPromptService", () => {
describe("error state", () => {
beforeEach(() => {
service.start();
service.registerPopupUrl(VaultMessages.OpenAtRiskPasswords);
jest.advanceTimersByTime(1000);
});
@@ -172,19 +181,17 @@ describe("BrowserExtensionPromptService", () => {
});
});
it("sets manual open state when open extension is called", (done) => {
service.openExtension(true);
it("sets manual open state when open extension is called", async () => {
const pageState$: Observable<BrowserPromptState> = service.pageState$;
await service.openExtension(VaultMessages.OpenAtRiskPasswords, true);
jest.advanceTimersByTime(1000);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.ManualOpen);
done();
});
expect(await firstValueFrom(pageState$)).toBe(BrowserPromptState.ManualOpen);
});
it("shows success state when extension auto opens", (done) => {
service.openExtension(true);
it("shows success state when extension auto opens", async () => {
await service.openExtension(VaultMessages.OpenAtRiskPasswords, true);
jest.advanceTimersByTime(500); // don't let timeout occur
@@ -192,11 +199,9 @@ describe("BrowserExtensionPromptService", () => {
new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }),
);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.Success);
expect(service["extensionCheckTimeout"]).toBeUndefined();
done();
});
const pageState$: Observable<BrowserPromptState> = service.pageState$;
expect(await firstValueFrom(pageState$)).toBe(BrowserPromptState.Success);
expect(service["extensionCheckTimeout"]).toBeUndefined();
});
});
});

View File

@@ -4,10 +4,13 @@ import { BehaviorSubject, fromEvent } from "rxjs";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { AnonLayoutWrapperDataService } from "@bitwarden/components";
import { WebBrowserInteractionService } from "./web-browser-interaction.service";
export const BrowserPromptState = {
Loading: "loading",
Error: "error",
@@ -36,6 +39,7 @@ export class BrowserExtensionPromptService {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private destroyRef: DestroyRef,
private platformUtilsService: PlatformUtilsService,
private webBrowserInteractionService: WebBrowserInteractionService,
) {}
start(): void {
@@ -52,14 +56,19 @@ export class BrowserExtensionPromptService {
this.setErrorState(BrowserPromptState.ManualOpen);
return;
}
}
this.checkForBrowserExtension();
registerPopupUrl(url: string) {
this.checkForBrowserExtension(url);
}
/** Post a message to the extension to open */
openExtension(setManualErrorTimeout = false) {
window.postMessage({ command: VaultMessages.OpenAtRiskPasswords });
async openExtension(url: string, setManualErrorTimeout = false) {
if (url == VaultMessages.OpenAtRiskPasswords) {
window.postMessage({ command: url });
} else {
await this.webBrowserInteractionService.openExtension(ExtensionPageUrls[url]);
}
// Optionally, configure timeout to show the manual open error state if
// the extension does not open within one second.
if (setManualErrorTimeout) {
@@ -72,11 +81,11 @@ export class BrowserExtensionPromptService {
}
/** Send message checking for the browser extension */
private checkForBrowserExtension() {
private checkForBrowserExtension(url: string) {
fromEvent<MessageEvent>(window, "message")
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
void this.getMessages(event);
void this.getMessages(event, url);
});
window.postMessage({ command: VaultMessages.checkBwInstalled });
@@ -88,9 +97,9 @@ export class BrowserExtensionPromptService {
}
/** Handle window message events */
private getMessages(event: any) {
private async getMessages(event: any, url: string) {
if (event.data.command === VaultMessages.HasBwInstalled) {
this.openExtension();
await this.openExtension(url);
}
if (event.data.command === VaultMessages.PopupOpened) {

View File

@@ -11,7 +11,7 @@
"criticalApplications": {
"message": "Critical applications"
},
"noCriticalAppsAtRisk":{
"noCriticalAppsAtRisk": {
"message": "No critical applications at risk"
},
"accessIntelligence": {
@@ -1719,7 +1719,6 @@
}
}
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
},
@@ -2090,9 +2089,6 @@
"encKeySettings": {
"message": "Encryption key settings"
},
"kdfAlgorithm": {
"message": "KDF algorithm"
},
"kdfIterations": {
"message": "KDF iterations"
},
@@ -2127,9 +2123,6 @@
"argon2Desc": {
"message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker."
},
"changeKdf": {
"message": "Change KDF"
},
"encKeySettingsChanged": {
"message": "Encryption key settings saved"
},
@@ -2146,22 +2139,22 @@
"message": "Proceeding will also log you out of your current session, requiring you to log back in. You will also be prompted for two-step login again, if set up. Active sessions on other devices may continue to remain active for up to one hour."
},
"newDeviceLoginProtection": {
"message":"New device login"
"message": "New device login"
},
"turnOffNewDeviceLoginProtection": {
"message":"Turn off new device login protection"
"message": "Turn off new device login protection"
},
"turnOnNewDeviceLoginProtection": {
"message":"Turn on new device login protection"
"message": "Turn on new device login protection"
},
"turnOffNewDeviceLoginProtectionModalDesc": {
"message":"Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
"message": "Proceed below to turn off the verification emails bitwarden sends when you login from a new device."
},
"turnOnNewDeviceLoginProtectionModalDesc": {
"message":"Proceed below to have bitwarden send you verification emails when you login from a new device."
"message": "Proceed below to have bitwarden send you verification emails when you login from a new device."
},
"turnOffNewDeviceLoginProtectionWarning": {
"message":"With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
"message": "With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login."
},
"accountNewDeviceLoginProtectionSaved": {
"message": "New device login protection changes saved"
@@ -2297,7 +2290,7 @@
"selectImportCollection": {
"message": "Select a collection"
},
"importTargetHintCollection": {
"importTargetHintCollection": {
"message": "Select this option if you want the imported file contents moved to a collection"
},
"importTargetHintFolder": {
@@ -5700,7 +5693,7 @@
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
},
"organizationDataOwnershipContentAnchor":{
"organizationDataOwnershipContentAnchor": {
"message": "credential lifecycle",
"description": "This will be used as a hyperlink"
},
@@ -5723,6 +5716,65 @@
"message": "Learn more about the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
},
"availableNow": {
"message": "Available now"
},
"autoConfirm": {
"message": "Automatic confirmation of new users"
},
"autoConfirmDescription": {
"message": "New users invited to the organization will be automatically confirmed when an admins device is unlocked.",
"description": "This is the description of the policy as it appears in the 'Policies' page"
},
"howToTurnOnAutoConfirm": {
"message": "How to turn on automatic user confirmation"
},
"autoConfirmStep1": {
"message": "Open your Bitwarden extension."
},
"autoConfirmStep2a": {
"message": "Select",
"description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'"
},
"autoConfirmStep2b": {
"message": " Turn on.",
"description": "This is a fragment of a larger sencence. The whole sentence will read: 'Select Turn on.'"
},
"autoConfirmExtensionOpened": {
"message": "Successfully opened the Bitwarden browser extension. You can now activate the automatic user confirmation setting."
},
"autoConfirmPolicyEditDescription": {
"message": "New users invited to the organization will be automatically confirmed when an admins device is unlocked. Before turning on this policy, please review and agree to the following: ",
"description": "This is the description of the policy as it appears inside the policy edit dialog"
},
"autoConfirmAcceptSecurityRiskTitle": {
"message": "Potential security risk. "
},
"autoConfirmAcceptSecurityRiskDescription": {
"message": "Automatic user confirmation could pose a security risk to your organizations data."
},
"autoConfirmAcceptSecurityRiskLearnMore": {
"message": "Learn about the risks",
"description": "The is the link copy for the first check box option in the edit policy dialog"
},
"autoConfirmSingleOrgRequired": {
"message": "Single organization policy required. "
},
"autoConfirmSingleOrgRequiredDescription": {
"message": "Anyone part of more than one organization will have their access revoked until they leave the other organizations."
},
"autoConfirmSingleOrgExemption": {
"message": "Single organization policy will extend to all roles. "
},
"autoConfirmNoEmergencyAccess": {
"message": "No emergency access. "
},
"autoConfirmNoEmergencyAccessDescription": {
"message": "Emergency Access will be removed."
},
"autoConfirmCheckBoxLabel": {
"message": "I accept these risks and policy updates"
},
"personalOwnership": {
"message": "Remove individual vault"
},
@@ -10374,27 +10426,9 @@
"memberAccessReportAuthenticationEnabledFalse": {
"message": "Off"
},
"higherKDFIterations": {
"message": "Higher KDF iterations can help protect your master password from being brute forced by an attacker."
},
"incrementsOf100,000": {
"message": "increments of 100,000"
},
"smallIncrements": {
"message": "small increments"
},
"kdfIterationRecommends": {
"message": "We recommend 600,000 or more"
},
"kdfToHighWarningIncreaseInIncrements": {
"message": "For older devices, setting your KDF too high may lead to performance issues. Increase the value in $VALUE$ and test your devices.",
"placeholders": {
"value": {
"content": "$1",
"example": "increments of 100,000"
}
}
},
"providerReinstate": {
"message": " Contact Customer Support to reinstate your subscription."
},
@@ -11079,7 +11113,7 @@
"orgTrustWarning1": {
"message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint."
},
"trustUser":{
"trustUser": {
"message": "Trust user"
},
"sshKeyWrongPassword": {
@@ -11115,7 +11149,7 @@
"openingExtension": {
"message": "Opening the Bitwarden browser extension"
},
"somethingWentWrong":{
"somethingWentWrong": {
"message": "Something went wrong..."
},
"openingExtensionError": {
@@ -11202,7 +11236,7 @@
}
}
},
"accountDeprovisioningNotification" : {
"accountDeprovisioningNotification": {
"message": "Administrators now have the ability to delete member accounts that belong to a claimed domain."
},
"deleteManagedUserWarningDesc": {
@@ -11293,14 +11327,14 @@
"upgradeForFullEventsMessage": {
"message": "Event logs are not stored for your organization. Upgrade to a Teams or Enterprise plan to get full access to organization event logs."
},
"upgradeEventLogTitleMessage" : {
"message" : "Upgrade to see event logs from your organization."
"upgradeEventLogTitleMessage": {
"message": "Upgrade to see event logs from your organization."
},
"upgradeEventLogMessage":{
"message" : "These events are examples only and do not reflect real events within your Bitwarden organization."
"upgradeEventLogMessage": {
"message": "These events are examples only and do not reflect real events within your Bitwarden organization."
},
"viewEvents":{
"message" : "View Events"
"viewEvents": {
"message": "View Events"
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
@@ -11619,14 +11653,14 @@
}
}
},
"unlimitedSecretsAndProjects": {
"unlimitedSecretsAndProjects": {
"message": "Unlimited secrets and projects"
},
"providersubscriptionCanceled": {
"providersubscriptionCanceled": {
"message": "Subscription canceled"
},
"providersubCanceledmessage": {
"message" : "To resubscribe, contact Bitwarden Customer Support."
"message": "To resubscribe, contact Bitwarden Customer Support."
},
"showMore": {
"message": "Show more"
@@ -11878,5 +11912,32 @@
},
"viewbusinessplans": {
"message": "View business plans"
},
"updateEncryptionSettings": {
"message": "Update encryption settings"
},
"updateYourEncryptionSettings": {
"message": "Update your encryption settings"
},
"updateSettings": {
"message": "Update settings"
},
"algorithm": {
"message": "Algorithm"
},
"encryptionKeySettingsHowShouldWeEncryptYourData": {
"message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users."
},
"encryptionKeySettingsIncreaseImproveSecurity": {
"message": "Increasing the values above the default will improve security, but your vault may take longer to unlock as a result."
},
"encryptionKeySettingsAlgorithmPopoverTitle": {
"message": "About encryption algorithms"
},
"encryptionKeySettingsAlgorithmPopoverPBKDF2": {
"message": "PBKDF2-SHA256 is a well-tested encryption method that balances security and performance. Good for all users."
},
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
}
}

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