1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

Merge remote-tracking branch 'origin/km/browser-integration-supported-platform-check' into km/tmp-biometrics-fixed

This commit is contained in:
Bernd Schoolmann
2025-01-10 13:12:05 +01:00
64 changed files with 757 additions and 559 deletions

View File

@@ -1,41 +0,0 @@
name: Bump Desktop Cask
on:
push:
tags:
- desktop-v**
workflow_dispatch:
defaults:
run:
shell: bash
jobs:
update-desktop-cask:
name: Update Bitwarden Desktop Cask
runs-on: macos-13
steps:
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "brew-bump-workflow-pat"
- name: Update Homebrew cask
uses: macauley/action-homebrew-bump-cask@445c42390d790569d938f9068d01af39ca030feb # v1.0.0
with:
# Required, custom GitHub access token with the 'public_repo' and 'workflow' scopes
token: ${{ steps.retrieve-secrets.outputs.brew-bump-workflow-pat }}
org: bitwarden
tap: Homebrew/homebrew-cask
cask: bitwarden
tag: ${{ github.ref }}
revision: ${{ github.sha }}
force: true
dryrun: true

View File

@@ -21,6 +21,7 @@ import {
ToastOptions,
ToastService,
} from "@bitwarden/components";
import { BiometricStateService } from "@bitwarden/key-management";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
import { PopupViewCacheService } from "../platform/popup/view-cache/popup-view-cache.service";
@@ -64,6 +65,7 @@ export class AppComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private accountService: AccountService,
private animationControlService: AnimationControlService,
private biometricStateService: BiometricStateService,
) {}
async ngOnInit() {
@@ -134,6 +136,7 @@ export class AppComponent implements OnInit, OnDestroy {
} else if (msg.command === "reloadProcess") {
if (this.platformUtilsService.isSafari()) {
window.setTimeout(() => {
this.biometricStateService.updateLastProcessReload();
window.location.reload();
}, 2000);
}

View File

@@ -361,20 +361,17 @@ describe("VaultPopupItemsService", () => {
});
describe("deletedCiphers$", () => {
it("should return deleted ciphers", (done) => {
const ciphers = [
{ id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true },
{ id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true },
{ id: "4", type: CipherType.Login, name: "Login 4", isDeleted: false },
] as CipherView[];
it("should return deleted ciphers", async () => {
const deletedCipher = new CipherView();
deletedCipher.deletedDate = new Date();
const ciphers = [new CipherView(), new CipherView(), new CipherView(), deletedCipher];
cipherServiceMock.getAllDecrypted.mockResolvedValue(ciphers);
service.deletedCiphers$.subscribe((deletedCiphers) => {
expect(deletedCiphers.length).toBe(3);
done();
});
(cipherServiceMock.ciphers$ as BehaviorSubject<any>).next(null);
const deletedCiphers = await firstValueFrom(service.deletedCiphers$);
expect(deletedCiphers.length).toBe(1);
});
});

View File

@@ -247,8 +247,28 @@ export class VaultPopupItemsService {
/**
* Observable that contains the list of ciphers that have been deleted.
*/
deletedCiphers$: Observable<CipherView[]> = this._allDecryptedCiphers$.pipe(
map((ciphers) => ciphers.filter((c) => c.isDeleted)),
deletedCiphers$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) =>
combineLatest([
this.organizationService.organizations$,
this.collectionService.decryptedCollections$,
]).pipe(
map(([organizations, collections]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers
.filter((c) => c.isDeleted)
.map(
(cipher) =>
new PopupCipherView(
cipher,
cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]),
orgMap[cipher.organizationId as OrganizationId],
),
);
}),
),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);

View File

@@ -13,8 +13,23 @@
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
(click)="onViewCipher(cipher)"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
<i
*ngIf="cipher.organizationId"
appOrgIcon
[tierType]="cipher.organization.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="cipher.hasAttachments"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
</button>
<ng-container slot="end" *ngIf="cipher.edit">
<bit-item-action>

View File

@@ -23,9 +23,12 @@ import {
import {
CanDeleteCipherDirective,
DecryptionFailureDialogComponent,
OrgIconDirective,
PasswordRepromptService,
} from "@bitwarden/vault";
import { PopupCipherView } from "../../views/popup-cipher.view";
@Component({
selector: "app-trash-list-items-container",
templateUrl: "trash-list-items-container.component.html",
@@ -39,6 +42,7 @@ import {
CanDeleteCipherDirective,
MenuModule,
IconButtonModule,
OrgIconDirective,
TypographyModule,
DecryptionFailureDialogComponent,
],
@@ -49,7 +53,7 @@ export class TrashListItemsContainerComponent {
* The list of trashed items to display.
*/
@Input()
ciphers: CipherView[] = [];
ciphers: PopupCipherView[] = [];
@Input()
headerText: string;
@@ -64,6 +68,17 @@ export class TrashListItemsContainerComponent {
private router: Router,
) {}
/**
* The tooltip text for the organization icon for ciphers that belong to an organization.
*/
orgIconTooltip(cipher: PopupCipherView) {
if (cipher.collectionIds.length > 1) {
return this.i18nService.t("nCollections", cipher.collectionIds.length);
}
return cipher.collections[0]?.name;
}
async restore(cipher: CipherView) {
try {
await this.cipherService.restoreWithServer(cipher.id);

View File

@@ -8,7 +8,6 @@ import { VaultIcons } from "@bitwarden/vault";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { VaultListItemsContainerComponent } from "../components/vault-v2/vault-list-items-container/vault-list-items-container.component";
import { VaultPopupItemsService } from "../services/vault-popup-items.service";
import { TrashListItemsContainerComponent } from "./trash-list-items-container/trash-list-items-container.component";
@@ -22,7 +21,6 @@ import { TrashListItemsContainerComponent } from "./trash-list-items-container/t
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
VaultListItemsContainerComponent,
TrashListItemsContainerComponent,
CalloutModule,
NoItemsModule,

View File

@@ -5,6 +5,7 @@ config.content = [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
"../../libs/key-management/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",

View File

@@ -916,7 +916,6 @@ dependencies = [
"pin-project",
"pkcs8",
"rand",
"retry",
"rsa",
"russh-cryptovec",
"scopeguard",
@@ -2388,15 +2387,6 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "retry"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9166d72162de3575f950507683fac47e30f6f2c3836b71b7fbc61aa517c9c5f4"
dependencies = [
"rand",
]
[[package]]
name = "rsa"
version = "0.9.6"

View File

@@ -6,18 +6,16 @@ version = "0.0.0"
publish = false
[features]
default = ["sys"]
manual_test = []
sys = [
default = [
"dep:widestring",
"dep:windows",
"dep:core-foundation",
"dep:security-framework",
"dep:security-framework-sys",
"dep:zbus",
"dep:zbus_polkit",
"dep:zbus_polkit"
]
manual_test = []
[dependencies]
aes = "=0.8.4"
@@ -36,7 +34,6 @@ futures = "=0.3.31"
interprocess = { version = "=2.2.1", features = ["tokio"] }
log = "=0.4.22"
rand = "=0.8.5"
retry = "=2.0.0"
russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0"
sha2 = "=0.10.8"

View File

@@ -3,12 +3,16 @@ use anyhow::{anyhow, Result};
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
pub use biometric::Biometric;
#[cfg(target_os = "windows")]
pub mod windows_focus;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};

View File

@@ -1,12 +1,15 @@
use std::{ffi::c_void, str::FromStr};
use std::{
ffi::c_void,
str::FromStr,
sync::{atomic::AtomicBool, Arc},
};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use retry::delay::Fixed;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, h, s, HSTRING},
core::{factory, h, HSTRING},
Foundation::IAsyncOperation,
Security::{
Credentials::{
@@ -14,17 +17,7 @@ use windows::{
},
Cryptography::CryptographicBuffer,
},
Win32::{
Foundation::HWND,
System::WinRT::IUserConsentVerifierInterop,
UI::{
Input::KeyboardAndMouse::{
keybd_event, GetAsyncKeyState, SetFocus, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP,
VK_MENU,
},
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
},
},
Win32::{Foundation::HWND, System::WinRT::IUserConsentVerifierInterop},
};
use crate::{
@@ -32,7 +25,10 @@ use crate::{
crypto::CipherString,
};
use super::{decrypt, encrypt};
use super::{
decrypt, encrypt,
windows_focus::{focus_security_prompt, set_focus},
};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
@@ -103,8 +99,22 @@ impl super::BiometricTrait for Biometric {
let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?;
let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?;
focus_security_prompt()?;
let signature = async_operation.get()?;
focus_security_prompt();
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let _ = std::thread::spawn(move || loop {
if !done_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
let signature = async_operation.get();
done.store(true, std::sync::atomic::Ordering::Relaxed);
let signature = signature?;
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
@@ -168,57 +178,6 @@ fn random_challenge() -> [u8; 16] {
challenge
}
/// Searches for a window that looks like a security prompt and set it as focused.
///
/// Gives up after 1.5 seconds with a delay of 500ms between each try.
fn focus_security_prompt() -> Result<()> {
unsafe fn try_find_and_set_focus(
class_name: windows::core::PCSTR,
) -> retry::OperationResult<(), ()> {
let hwnd = unsafe { FindWindowA(class_name, None) };
if let Ok(hwnd) = hwnd {
set_focus(hwnd);
return retry::OperationResult::Ok(());
}
retry::OperationResult::Retry(())
}
let class_name = s!("Credential Dialog Xaml Host");
retry::retry_with_index(Fixed::from_millis(500), |current_try| {
if current_try > 3 {
return retry::OperationResult::Err(());
}
unsafe { try_find_and_set_focus(class_name) }
})
.map_err(|_| anyhow!("Failed to find security prompt"))
}
fn set_focus(window: HWND) {
let mut pressed = false;
unsafe {
// Simulate holding down Alt key to bypass windows limitations
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getasynckeystate#return-value
// The most significant bit indicates if the key is currently being pressed. This means the
// value will be negative if the key is pressed.
if GetAsyncKeyState(VK_MENU.0 as i32) >= 0 {
pressed = true;
keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_EXTENDEDKEY, 0);
}
let _ = SetForegroundWindow(window);
let _ = SetFocus(window);
if pressed {
keybd_event(
VK_MENU.0 as u8,
0,
KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP,
0,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ publish = false
[dependencies]
anyhow = "=1.0.94"
desktop_core = { path = "../core", default-features = false }
desktop_core = { path = "../core" }
futures = "=0.3.31"
log = "=0.4.22"
simplelog = "=0.12.2"

View File

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

View File

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

View File

@@ -650,7 +650,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
const skipSupportedPlatformCheck =
ipc.platform.allowBrowserintegrationOverride || ipc.platform.isDev;
if (skipSupportedPlatformCheck) {
if (!skipSupportedPlatformCheck) {
if (
ipc.platform.deviceType === DeviceType.MacOsDesktop &&
!this.platformUtilsService.isMacAppStore()

View File

@@ -45,6 +45,8 @@ export class SshAgentService implements OnDestroy {
SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 60_000;
SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100;
private isFeatureFlagEnabled = false;
private destroy$ = new Subject<void>();
constructor(
@@ -65,18 +67,19 @@ export class SshAgentService implements OnDestroy {
.getFeatureFlag$(FeatureFlag.SSHAgent)
.pipe(
concatMap(async (enabled) => {
if (enabled && !(await ipc.platform.sshAgent.isLoaded())) {
return this.initSshAgent();
this.isFeatureFlagEnabled = enabled;
if (!(await ipc.platform.sshAgent.isLoaded()) && enabled) {
await ipc.platform.sshAgent.init();
}
}),
takeUntil(this.destroy$),
)
.subscribe();
await this.initListeners();
}
private async initSshAgent() {
await ipc.platform.sshAgent.init();
private async initListeners() {
this.messageListener
.messages$(new CommandDefinition("sshagent.signrequest"))
.pipe(
@@ -179,18 +182,30 @@ export class SshAgentService implements OnDestroy {
this.accountService.activeAccount$.pipe(skip(1), takeUntil(this.destroy$)).subscribe({
next: (account) => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.info("Active account changed, clearing SSH keys");
ipc.platform.sshAgent
.clearKeys()
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
},
error: (e: unknown) => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.error("Error in active account observable", e);
ipc.platform.sshAgent
.clearKeys()
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
},
complete: () => {
if (!this.isFeatureFlagEnabled) {
return;
}
this.logService.info("Active account observable completed, clearing SSH keys");
ipc.platform.sshAgent
.clearKeys()
@@ -204,11 +219,23 @@ export class SshAgentService implements OnDestroy {
])
.pipe(
concatMap(async ([, enabled]) => {
if (!this.isFeatureFlagEnabled) {
return;
}
if (!enabled) {
await ipc.platform.sshAgent.clearKeys();
return;
}
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
const authStatus = await firstValueFrom(
this.authService.authStatusFor$(activeAccount.id),
);
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
const ciphers = await this.cipherService.getAllDecrypted();
if (ciphers == null) {
await ipc.platform.sshAgent.lock();

View File

@@ -5,6 +5,7 @@ config.content = [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
"../../libs/key-management/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts,mdx}",
];

View File

@@ -23,14 +23,15 @@
<bit-tab [label]="'role' | i18n">
<ng-container *ngIf="!editMode">
<p bitTypography="body1">{{ "inviteUserDesc" | i18n }}</p>
<bit-form-field>
<bit-form-field *ngIf="remainingSeats$ | async as remainingSeats">
<bit-label>{{ "email" | i18n }}</bit-label>
<input id="emails" type="text" appAutoFocus bitInput formControlName="emails" />
<bit-hint>{{
"inviteMultipleEmailDesc"
| i18n
: (organization.productTierType === ProductTierType.TeamsStarter ? "10" : "20")
<bit-hint *ngIf="remainingSeats > 1; else singleSeat">{{
"inviteMultipleEmailDesc" | i18n: remainingSeats
}}</bit-hint>
<ng-template #singleSeat>
<bit-hint>{{ "inviteSingleEmailDesc" | i18n: remainingSeats }}</bit-hint>
</ng-template>
</bit-form-field>
</ng-container>
<bit-radio-group formControlName="type">

View File

@@ -94,6 +94,7 @@ export class MemberDialogComponent implements OnDestroy {
PermissionMode = PermissionMode;
showNoMasterPasswordWarning = false;
isOnSecretsManagerStandalone: boolean;
remainingSeats$: Observable<number>;
protected organization$: Observable<Organization>;
protected collectionAccessItems: AccessItemView[] = [];
@@ -260,6 +261,10 @@ export class MemberDialogComponent implements OnDestroy {
this.loading = false;
});
this.remainingSeats$ = this.organization$.pipe(
map((organization) => organization.seats - this.params.numConfirmedMembers),
);
}
private setFormValidators(organization: Organization) {

View File

@@ -231,6 +231,20 @@ export class AppComponent implements OnDestroy, OnInit {
}
break;
}
case "syncOrganizationCollectionSettingChanged": {
const { organizationId, limitCollectionCreation, limitCollectionDeletion } = message;
const organizations = await firstValueFrom(this.organizationService.organizations$);
const organization = organizations.find((org) => org.id === organizationId);
if (organization) {
await this.organizationService.upsert({
...organization,
limitCollectionCreation: limitCollectionCreation,
limitCollectionDeletion: limitCollectionDeletion,
});
}
break;
}
default:
break;
}

View File

@@ -117,14 +117,7 @@ describe("EmergencyAccessService", () => {
const granteeId = "grantee-id";
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockPublicKeyB64 = "some-public-key-in-base64";
// const publicKey = Utils.fromB64ToArray(publicKeyB64);
const mockUserPublicKeyResponse = new UserKeyResponse({
UserId: granteeId,
PublicKey: mockPublicKeyB64,
});
const publicKey = new Uint8Array(64);
const mockUserPublicKeyEncryptedUserKey = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
@@ -132,14 +125,13 @@ describe("EmergencyAccessService", () => {
);
keyService.getUserKey.mockResolvedValueOnce(mockUserKey);
apiService.getUserPublicKey.mockResolvedValueOnce(mockUserPublicKeyResponse);
encryptService.rsaEncrypt.mockResolvedValueOnce(mockUserPublicKeyEncryptedUserKey);
emergencyAccessApiService.postEmergencyAccessConfirm.mockResolvedValueOnce();
// Act
await emergencyAccessService.confirm(id, granteeId);
await emergencyAccessService.confirm(id, granteeId, publicKey);
// Assert
expect(emergencyAccessApiService.postEmergencyAccessConfirm).toHaveBeenCalledWith(id, {

View File

@@ -153,14 +153,13 @@ export class EmergencyAccessService
* Intended for grantor.
* @param id emergency access id
* @param token secret token provided in email
* @param publicKey public key of grantee
*/
async confirm(id: string, granteeId: string) {
async confirm(id: string, granteeId: string, publicKey: Uint8Array): Promise<void> {
const userKey = await this.keyService.getUserKey();
if (!userKey) {
throw new Error("No user key found");
}
const publicKeyResponse = await this.apiService.getUserPublicKey(granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
try {
this.logService.debug(

View File

@@ -4,10 +4,8 @@ import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -21,6 +19,8 @@ type EmergencyAccessConfirmDialogData = {
userId: string;
/** traces a unique emergency request */
emergencyAccessId: string;
/** user public key */
publicKey: Uint8Array;
};
@Component({
selector: "emergency-access-confirm",
@@ -36,7 +36,6 @@ export class EmergencyAccessConfirmComponent implements OnInit {
constructor(
@Inject(DIALOG_DATA) protected params: EmergencyAccessConfirmDialogData,
private formBuilder: FormBuilder,
private apiService: ApiService,
private keyService: KeyService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private logService: LogService,
@@ -45,13 +44,12 @@ export class EmergencyAccessConfirmComponent implements OnInit {
async ngOnInit() {
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(this.params.userId);
if (publicKeyResponse != null) {
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const fingerprint = await this.keyService.getFingerprint(this.params.userId, publicKey);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
const fingerprint = await this.keyService.getFingerprint(
this.params.userId,
this.params.publicKey,
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
} catch (e) {
this.logService.error(e);

View File

@@ -4,6 +4,7 @@ import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { lastValueFrom, Observable, firstValueFrom, switchMap } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -13,6 +14,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { EmergencyAccessService } from "../../emergency-access";
@@ -70,6 +72,7 @@ export class EmergencyAccessComponent implements OnInit {
billingAccountProfileStateService: BillingAccountProfileStateService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private toastService: ToastService,
private apiService: ApiService,
private accountService: AccountService,
) {
this.canAccessPremium$ = this.accountService.activeAccount$.pipe(
@@ -147,6 +150,9 @@ export class EmergencyAccessComponent implements OnInit {
return;
}
const publicKeyResponse = await this.apiService.getUserPublicKey(contact.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
@@ -156,11 +162,12 @@ export class EmergencyAccessComponent implements OnInit {
name: this.userNamePipe.transform(contact),
emergencyAccessId: contact.id,
userId: contact?.granteeId,
publicKey,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === EmergencyAccessConfirmDialogResult.Confirmed) {
await this.emergencyAccessService.confirm(contact.id, contact.granteeId);
await this.emergencyAccessService.confirm(contact.id, contact.granteeId, publicKey);
updateUser();
this.toastService.showToast({
variant: "success",
@@ -171,7 +178,11 @@ export class EmergencyAccessComponent implements OnInit {
return;
}
this.actionPromise = this.emergencyAccessService.confirm(contact.id, contact.granteeId);
this.actionPromise = this.emergencyAccessService.confirm(
contact.id,
contact.granteeId,
publicKey,
);
await this.actionPromise;
updateUser();

View File

@@ -21,7 +21,7 @@
</div>
</div>
<p>{{ "deviceListDescription" | i18n }}</p>
<p>{{ "deviceListDescriptionTemp" | i18n }}</p>
<div *ngIf="loading" class="tw-flex tw-justify-center tw-items-center tw-p-4">
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
@@ -63,13 +63,14 @@
}}</span>
</td>
<td bitCell>{{ row.firstLogin | date: "medium" }}</td>
<td bitCell>
<!-- <td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
[bitMenuTriggerFor]="optionsMenu"
></button>
<bit-menu #optionsMenu>
Remove device button to be re-added later when we have per device session de-authentication.
<button
type="button"
bitMenuItem
@@ -82,7 +83,7 @@
</span>
</button>
</bit-menu>
</td>
</td> -->
</ng-template>
</bit-table-scroll>
</bit-container>

View File

@@ -2,6 +2,7 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
@@ -22,6 +23,14 @@ export default {
moduleMetadata({
imports: [JslibModule, BadgeModule],
providers: [
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "123",
}),
},
},
{
provide: I18nService,
useFactory: () => {
@@ -39,7 +48,7 @@ export default {
{
provide: BillingAccountProfileStateService,
useValue: {
hasPremiumFromAnySource$: of(false),
hasPremiumFromAnySource$: () => of(false),
},
},
],

View File

@@ -1,11 +1,14 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
@@ -22,18 +25,20 @@ describe("VaultBannersService", () => {
let service: VaultBannersService;
const isSelfHost = jest.fn().mockReturnValue(false);
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
const userId = "user-id" as UserId;
const userId = Utils.newGuid() as UserId;
const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
const getEmailVerified = jest.fn().mockResolvedValue(true);
const hasMasterPassword = jest.fn().mockResolvedValue(true);
const getKdfConfig = jest
.fn()
.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 });
const getLastSync = jest.fn().mockResolvedValue(null);
const lastSync$ = new BehaviorSubject<Date | null>(null);
const userDecryptionOptions$ = new BehaviorSubject<UserDecryptionOptions>({
hasMasterPassword: true,
});
const kdfConfig$ = new BehaviorSubject({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 });
const accounts$ = new BehaviorSubject<Record<UserId, AccountInfo>>({
[userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo,
});
beforeEach(() => {
jest.useFakeTimers();
getLastSync.mockClear().mockResolvedValue(new Date("2024-05-14"));
lastSync$.next(new Date("2024-05-14"));
isSelfHost.mockClear();
getEmailVerified.mockClear().mockResolvedValue(true);
@@ -52,25 +57,27 @@ describe("VaultBannersService", () => {
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: PlatformUtilsService,
useValue: { isSelfHost },
},
{
provide: AccountService,
useValue: mockAccountServiceWith(userId),
},
{
provide: TokenService,
useValue: { getEmailVerified },
},
{
provide: UserVerificationService,
useValue: { hasMasterPassword },
useValue: { accounts$ },
},
{
provide: KdfConfigService,
useValue: { getKdfConfig },
useValue: { getKdfConfig$: () => kdfConfig$ },
},
{
provide: SyncService,
useValue: { getLastSync },
useValue: { lastSync$: () => lastSync$ },
},
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: {
userDecryptionOptionsById$: () => userDecryptionOptions$,
},
},
],
});
@@ -82,39 +89,38 @@ describe("VaultBannersService", () => {
describe("Premium", () => {
it("waits until sync is completed before showing premium banner", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(false);
lastSync$.next(null);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
const premiumBanner$ = service.shouldShowPremiumBanner$(userId);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(true);
// Should not emit when sync is null
await expect(firstValueFrom(premiumBanner$.pipe(take(1), timeout(100)))).rejects.toThrow();
// Should emit when sync is completed
lastSync$.next(new Date("2024-05-14"));
expect(await firstValueFrom(premiumBanner$)).toBe(true);
});
it("does not show a premium banner for self-hosted users", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(false);
isSelfHost.mockReturnValue(true);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
});
it("does not show a premium banner when they have access to premium", async () => {
getLastSync.mockResolvedValue(new Date("2024-05-14"));
hasPremiumFromAnySource$.next(true);
isSelfHost.mockReturnValue(false);
service = TestBed.inject(VaultBannersService);
jest.advanceTimersByTime(201);
expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false);
expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false);
});
describe("dismissing", () => {
@@ -125,7 +131,7 @@ describe("VaultBannersService", () => {
jest.setSystemTime(date.getTime());
service = TestBed.inject(VaultBannersService);
await service.dismissBanner(VisibleVaultBanner.Premium);
await service.dismissBanner(userId, VisibleVaultBanner.Premium);
});
afterEach(() => {
@@ -134,7 +140,7 @@ describe("VaultBannersService", () => {
it("updates state on first dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneWeekLater = new Date("2023-06-15");
@@ -148,7 +154,7 @@ describe("VaultBannersService", () => {
it("updates state on second dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneMonthLater = new Date("2023-07-08");
@@ -162,7 +168,7 @@ describe("VaultBannersService", () => {
it("updates state on third dismiss", async () => {
const state = await firstValueFrom(
fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$,
fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$,
);
const oneYearLater = new Date("2024-06-08");
@@ -178,40 +184,40 @@ describe("VaultBannersService", () => {
describe("KDFSettings", () => {
beforeEach(async () => {
hasMasterPassword.mockResolvedValue(true);
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 });
userDecryptionOptions$.next({ hasMasterPassword: true });
kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 });
});
it("shows low KDF iteration banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(true);
expect(await service.shouldShowLowKDFBanner(userId)).toBe(true);
});
it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => {
getKdfConfig.mockResolvedValue({ kdfType: KdfType.Argon2id, iterations: 600001 });
kdfConfig$.next({ kdfType: KdfType.Argon2id, iterations: 600001 });
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
});
it("does not show low KDF for iterations about 600,000", async () => {
getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 });
kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 });
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
});
it("dismisses low KDF iteration banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowLowKDFBanner()).toBe(true);
expect(await service.shouldShowLowKDFBanner(userId)).toBe(true);
await service.dismissBanner(VisibleVaultBanner.KDFSettings);
await service.dismissBanner(userId, VisibleVaultBanner.KDFSettings);
expect(await service.shouldShowLowKDFBanner()).toBe(false);
expect(await service.shouldShowLowKDFBanner(userId)).toBe(false);
});
});
@@ -228,39 +234,44 @@ describe("VaultBannersService", () => {
it("shows outdated browser banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(true);
});
it("dismisses outdated browser banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(true);
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(true);
await service.dismissBanner(VisibleVaultBanner.OutdatedBrowser);
await service.dismissBanner(userId, VisibleVaultBanner.OutdatedBrowser);
expect(await service.shouldShowUpdateBrowserBanner()).toBe(false);
expect(await service.shouldShowUpdateBrowserBanner(userId)).toBe(false);
});
});
describe("VerifyEmail", () => {
beforeEach(async () => {
getEmailVerified.mockResolvedValue(false);
accounts$.next({
[userId]: {
...accounts$.value[userId],
emailVerified: false,
},
});
});
it("shows verify email banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
});
it("dismisses verify email banner", async () => {
service = TestBed.inject(VaultBannersService);
expect(await service.shouldShowVerifyEmailBanner()).toBe(true);
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(true);
await service.dismissBanner(VisibleVaultBanner.VerifyEmail);
await service.dismissBanner(userId, VisibleVaultBanner.VerifyEmail);
expect(await service.shouldShowVerifyEmailBanner()).toBe(false);
expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(false);
});
});
});

View File

@@ -1,28 +1,18 @@
import { Injectable } from "@angular/core";
import {
Subject,
Observable,
combineLatest,
firstValueFrom,
map,
mergeMap,
take,
switchMap,
of,
} from "rxjs";
import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
StateProvider,
ActiveUserState,
PREMIUM_BANNER_DISK_LOCAL,
BANNERS_DISMISSED_DISK,
UserKeyDefinition,
SingleUserState,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PBKDF2KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management";
@@ -62,47 +52,25 @@ export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition<SessionBanners[]
@Injectable()
export class VaultBannersService {
shouldShowPremiumBanner$: Observable<boolean>;
private premiumBannerState: ActiveUserState<PremiumBannerReprompt>;
private sessionBannerState: ActiveUserState<SessionBanners[]>;
/**
* Emits when the sync service has completed a sync
*
* This is needed because `hasPremiumFromAnySource$` will emit false until the sync is completed
* resulting in the premium banner being shown briefly on startup when the user has access to
* premium features.
*/
private syncCompleted$ = new Subject<void>();
constructor(
private tokenService: TokenService,
private userVerificationService: UserVerificationService,
private accountService: AccountService,
private stateProvider: StateProvider,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private platformUtilsService: PlatformUtilsService,
private kdfConfigService: KdfConfigService,
private syncService: SyncService,
private accountService: AccountService,
) {
this.pollUntilSynced();
this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY);
this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY);
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
) {}
const premiumSources$ = this.accountService.activeAccount$.pipe(
take(1),
switchMap((account) => {
return combineLatest([
account
? this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id)
: of(false),
this.premiumBannerState.state$,
]);
}),
);
shouldShowPremiumBanner$(userId: UserId): Observable<boolean> {
const premiumBannerState = this.premiumBannerState(userId);
const premiumSources$ = combineLatest([
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
premiumBannerState.state$,
]);
this.shouldShowPremiumBanner$ = this.syncCompleted$.pipe(
return this.syncService.lastSync$(userId).pipe(
filter((lastSync) => lastSync !== null),
take(1), // Wait until the first sync is complete before considering the premium status
mergeMap(() => premiumSources$),
map(([canAccessPremium, dismissedState]) => {
@@ -122,9 +90,9 @@ export class VaultBannersService {
}
/** Returns true when the update browser banner should be shown */
async shouldShowUpdateBrowserBanner(): Promise<boolean> {
async shouldShowUpdateBrowserBanner(userId: UserId): Promise<boolean> {
const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1;
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
VisibleVaultBanner.OutdatedBrowser,
);
@@ -132,10 +100,12 @@ export class VaultBannersService {
}
/** Returns true when the verify email banner should be shown */
async shouldShowVerifyEmailBanner(): Promise<boolean> {
const needsVerification = !(await this.tokenService.getEmailVerified());
async shouldShowVerifyEmailBanner(userId: UserId): Promise<boolean> {
const needsVerification = !(
await firstValueFrom(this.accountService.accounts$.pipe(map((accounts) => accounts[userId])))
)?.emailVerified;
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
VisibleVaultBanner.VerifyEmail,
);
@@ -143,12 +113,14 @@ export class VaultBannersService {
}
/** Returns true when the low KDF iteration banner should be shown */
async shouldShowLowKDFBanner(): Promise<boolean> {
const hasLowKDF = (await this.userVerificationService.hasMasterPassword())
? await this.isLowKdfIteration()
async shouldShowLowKDFBanner(userId: UserId): Promise<boolean> {
const hasLowKDF = (
await firstValueFrom(this.userDecryptionOptionsService.userDecryptionOptionsById$(userId))
)?.hasMasterPassword
? await this.isLowKdfIteration(userId)
: false;
const alreadyDismissed = (await this.getBannerDismissedState()).includes(
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
VisibleVaultBanner.KDFSettings,
);
@@ -156,11 +128,11 @@ export class VaultBannersService {
}
/** Dismiss the given banner and perform any respective side effects */
async dismissBanner(banner: SessionBanners): Promise<void> {
async dismissBanner(userId: UserId, banner: SessionBanners): Promise<void> {
if (banner === VisibleVaultBanner.Premium) {
await this.dismissPremiumBanner();
await this.dismissPremiumBanner(userId);
} else {
await this.sessionBannerState.update((current) => {
await this.sessionBannerState(userId).update((current) => {
const bannersDismissed = current ?? [];
return [...bannersDismissed, banner];
@@ -168,16 +140,32 @@ export class VaultBannersService {
}
}
/**
*
* @returns a SingleUserState for the premium banner reprompt state
*/
private premiumBannerState(userId: UserId): SingleUserState<PremiumBannerReprompt> {
return this.stateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY);
}
/**
*
* @returns a SingleUserState for the session banners dismissed state
*/
private sessionBannerState(userId: UserId): SingleUserState<SessionBanners[]> {
return this.stateProvider.getUser(userId, BANNERS_DISMISSED_DISK_KEY);
}
/** Returns banners that have already been dismissed */
private async getBannerDismissedState(): Promise<SessionBanners[]> {
private async getBannerDismissedState(userId: UserId): Promise<SessionBanners[]> {
// `state$` can emit null when a value has not been set yet,
// use nullish coalescing to default to an empty array
return (await firstValueFrom(this.sessionBannerState.state$)) ?? [];
return (await firstValueFrom(this.sessionBannerState(userId).state$)) ?? [];
}
/** Increment dismissal state of the premium banner */
private async dismissPremiumBanner(): Promise<void> {
await this.premiumBannerState.update((current) => {
private async dismissPremiumBanner(userId: UserId): Promise<void> {
await this.premiumBannerState(userId).update((current) => {
const numberOfDismissals = current?.numberOfDismissals ?? 0;
const now = new Date();
@@ -213,22 +201,11 @@ export class VaultBannersService {
});
}
private async isLowKdfIteration() {
const kdfConfig = await this.kdfConfigService.getKdfConfig();
private async isLowKdfIteration(userId: UserId) {
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
return (
kdfConfig.kdfType === KdfType.PBKDF2_SHA256 &&
kdfConfig.iterations < PBKDF2KdfConfig.ITERATIONS.defaultValue
);
}
/** Poll the `syncService` until a sync is completed */
private pollUntilSynced() {
const interval = setInterval(async () => {
const lastSync = await this.syncService.getLastSync();
if (lastSync !== null) {
clearInterval(interval);
this.syncCompleted$.next();
}
}, 200);
}
}

View File

@@ -2,13 +2,17 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { BannerComponent, BannerModule } from "@bitwarden/components";
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
@@ -21,21 +25,25 @@ describe("VaultBannersComponent", () => {
let component: VaultBannersComponent;
let fixture: ComponentFixture<VaultBannersComponent>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const mockUserId = Utils.newGuid() as UserId;
const bannerService = mock<VaultBannersService>({
shouldShowPremiumBanner$: premiumBanner$,
shouldShowPremiumBanner$: jest.fn((userId$: Observable<UserId>) => premiumBanner$),
shouldShowUpdateBrowserBanner: jest.fn(),
shouldShowVerifyEmailBanner: jest.fn(),
shouldShowLowKDFBanner: jest.fn(),
dismissBanner: jest.fn(),
});
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(async () => {
bannerService.shouldShowPremiumBanner$ = premiumBanner$;
bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false);
bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false);
bannerService.shouldShowLowKDFBanner.mockResolvedValue(false);
premiumBanner$.next(false);
await TestBed.configureTestingModule({
imports: [
BannerModule,
@@ -62,6 +70,10 @@ describe("VaultBannersComponent", () => {
provide: TokenService,
useValue: mock<TokenService>(),
},
{
provide: AccountService,
useValue: accountService,
},
],
})
.overrideProvider(VaultBannersService, { useValue: bannerService })
@@ -135,7 +147,7 @@ describe("VaultBannersComponent", () => {
dismissButton.dispatchEvent(new Event("click"));
expect(bannerService.dismissBanner).toHaveBeenCalledWith(banner);
expect(bannerService.dismissBanner).toHaveBeenCalledWith(mockUserId, banner);
expect(component.visibleBanners).toEqual([]);
});

View File

@@ -2,8 +2,9 @@
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { Observable } from "rxjs";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BannerModule } from "@bitwarden/components";
@@ -26,12 +27,17 @@ export class VaultBannersComponent implements OnInit {
VisibleVaultBanner = VisibleVaultBanner;
@Input() organizationsPaymentStatus: FreeTrial[] = [];
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
constructor(
private vaultBannerService: VaultBannersService,
private router: Router,
private i18nService: I18nService,
private accountService: AccountService,
) {
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
this.premiumBannerVisible$ = this.activeUserId$.pipe(
switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)),
);
}
async ngOnInit(): Promise<void> {
@@ -39,7 +45,8 @@ export class VaultBannersComponent implements OnInit {
}
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
await this.vaultBannerService.dismissBanner(banner);
const activeUserId = await firstValueFrom(this.activeUserId$);
await this.vaultBannerService.dismissBanner(activeUserId, banner);
await this.determineVisibleBanners();
}
@@ -57,9 +64,12 @@ export class VaultBannersComponent implements OnInit {
/** Determine which banners should be present */
private async determineVisibleBanners(): Promise<void> {
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner();
const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner();
const activeUserId = await firstValueFrom(this.activeUserId$);
const showBrowserOutdated =
await this.vaultBannerService.shouldShowUpdateBrowserBanner(activeUserId);
const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(activeUserId);
const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(activeUserId);
this.visibleBanners = [
showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null,

View File

@@ -1175,6 +1175,8 @@ export class VaultComponent implements OnInit, OnDestroy {
// Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === collection.id) {
// Clear the cipher cache to clear the deleted collection from the cipher state
await this.cipherService.clear();
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",

View File

@@ -3278,6 +3278,9 @@
}
}
},
"inviteSingleEmailDesc": {
"message": "You have 1 invite remaining."
},
"userUsingTwoStep": {
"message": "This user is using two-step login to protect their account."
},
@@ -9992,6 +9995,9 @@
"deviceListDescription": {
"message": "Your account was logged in to each of the devices below. If you do not recognize a device, remove it now."
},
"deviceListDescriptionTemp": {
"message": "Your account was logged in to each of the devices below."
},
"claimedDomains": {
"message": "Claimed domains"
},

View File

@@ -5,6 +5,7 @@ config.content = [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
"../../libs/key-management/src/**/*.{html,ts}",
"../../libs/vault/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
"../../bitwarden_license/bit-web/src/**/*.{html,ts}",

View File

@@ -180,10 +180,18 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
let decUserKey: Uint8Array;
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
decUserKey = await this.encryptService.decryptToBytes(userKey, masterKey);
decUserKey = await this.encryptService.decryptToBytes(
userKey,
masterKey,
"Content: User Key; Encrypting Key: Master Key",
);
} else if (userKey.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) {
const newKey = await this.keyGenerationService.stretchKey(masterKey);
decUserKey = await this.encryptService.decryptToBytes(userKey, newKey);
decUserKey = await this.encryptService.decryptToBytes(
userKey,
newKey,
"Content: User Key; Encrypting Key: Stretched Master Key",
);
} else {
throw new Error("Unsupported encryption type.");
}

View File

@@ -23,4 +23,5 @@ export enum NotificationType {
SyncOrganizations = 17,
SyncOrganizationStatusChanged = 18,
SyncOrganizationCollectionSettingChanged = 19,
}

View File

@@ -45,6 +45,9 @@ export class NotificationResponse extends BaseResponse {
case NotificationType.SyncOrganizationStatusChanged:
this.payload = new OrganizationStatusPushNotification(payload);
break;
case NotificationType.SyncOrganizationCollectionSettingChanged:
this.payload = new OrganizationCollectionSettingChangedPushNotification(payload);
break;
default:
break;
}
@@ -126,3 +129,17 @@ export class OrganizationStatusPushNotification extends BaseResponse {
this.enabled = this.getResponseProperty("Enabled");
}
}
export class OrganizationCollectionSettingChangedPushNotification extends BaseResponse {
organizationId: string;
limitCollectionCreation: boolean;
limitCollectionDeletion: boolean;
constructor(response: any) {
super(response);
this.organizationId = this.getResponseProperty("OrganizationId");
this.limitCollectionCreation = this.getResponseProperty("LimitCollectionCreation");
this.limitCollectionDeletion = this.getResponseProperty("LimitCollectionDeletion");
}
}

View File

@@ -8,12 +8,32 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
export abstract class EncryptService {
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
/**
* Decrypts an EncString to a string
* @param encString - The EncString to decrypt
* @param key - The key to decrypt the EncString with
* @param decryptTrace - A string to identify the context of the object being decrypted. This can include: field name, encryption type, cipher id, key type, but should not include
* sensitive information like encryption keys or data. This is used for logging when decryption errors occur in order to identify what failed to decrypt
* @returns The decrypted string
*/
abstract decryptToUtf8(
encString: EncString,
key: SymmetricCryptoKey,
decryptContext?: string,
decryptTrace?: string,
): Promise<string>;
abstract decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise<Uint8Array>;
/**
* Decrypts an Encrypted object to a Uint8Array
* @param encThing - The Encrypted object to decrypt
* @param key - The key to decrypt the Encrypted object with
* @param decryptTrace - A string to identify the context of the object being decrypted. This can include: field name, encryption type, cipher id, key type, but should not include
* sensitive information like encryption keys or data. This is used for logging when decryption errors occur in order to identify what failed to decrypt
* @returns The decrypted Uint8Array
*/
abstract decryptToBytes(
encThing: Encrypted,
key: SymmetricCryptoKey,
decryptTrace?: string,
): Promise<Uint8Array>;
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;

View File

@@ -63,6 +63,7 @@ export default class Domain {
map: any,
orgId: string,
key: SymmetricCryptoKey = null,
objectContext: string = "No Domain Context",
): Promise<T> {
const promises = [];
const self: any = this;
@@ -78,7 +79,11 @@ export default class Domain {
.then(() => {
const mapProp = map[theProp] || theProp;
if (self[mapProp]) {
return self[mapProp].decrypt(orgId, key);
return self[mapProp].decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
return null;
})
@@ -114,12 +119,21 @@ export default class Domain {
key: SymmetricCryptoKey,
encryptService: EncryptService,
_: Constructor<TThis> = this.constructor as Constructor<TThis>,
objectContext: string = "No Domain Context",
): Promise<DecryptedObject<TThis, TEncryptedKeys>> {
const promises = [];
for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
promises.push(this.decryptProperty(prop, value, key, encryptService));
promises.push(
this.decryptProperty(
prop,
value,
key,
encryptService,
`Property: ${prop.toString()}; ObjectContext: ${objectContext}`,
),
);
}
const decryptedObjects = await Promise.all(promises);
@@ -137,10 +151,11 @@ export default class Domain {
value: EncString,
key: SymmetricCryptoKey,
encryptService: EncryptService,
decryptTrace: string,
) {
let decrypted: string = null;
if (value) {
decrypted = await value.decryptWithKey(key, encryptService);
decrypted = await value.decryptWithKey(key, encryptService, decryptTrace);
} else {
decrypted = null;
}

View File

@@ -156,21 +156,21 @@ export class EncString implements Encrypted {
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
}
async decrypt(orgId: string, key: SymmetricCryptoKey = null): Promise<string> {
async decrypt(orgId: string, key: SymmetricCryptoKey = null, context?: string): Promise<string> {
if (this.decryptedValue != null) {
return this.decryptedValue;
}
let keyContext = "provided-key";
let decryptTrace = "provided-key";
try {
if (key == null) {
key = await this.getKeyForDecryption(orgId);
keyContext = orgId == null ? `domain-orgkey-${orgId}` : "domain-userkey|masterkey";
decryptTrace = orgId == null ? `domain-orgkey-${orgId}` : "domain-userkey|masterkey";
if (orgId != null) {
keyContext = `domain-orgkey-${orgId}`;
decryptTrace = `domain-orgkey-${orgId}`;
} else {
const cryptoService = Utils.getContainerService().getKeyService();
keyContext =
decryptTrace =
(await cryptoService.getUserKey()) == null
? "domain-withlegacysupport-masterkey"
: "domain-withlegacysupport-userkey";
@@ -181,20 +181,28 @@ export class EncString implements Encrypted {
}
const encryptService = Utils.getContainerService().getEncryptService();
this.decryptedValue = await encryptService.decryptToUtf8(this, key, keyContext);
this.decryptedValue = await encryptService.decryptToUtf8(
this,
key,
decryptTrace == null ? context : `${decryptTrace}${context || ""}`,
);
} catch (e) {
this.decryptedValue = DECRYPT_ERROR;
}
return this.decryptedValue;
}
async decryptWithKey(key: SymmetricCryptoKey, encryptService: EncryptService) {
async decryptWithKey(
key: SymmetricCryptoKey,
encryptService: EncryptService,
decryptTrace: string = "domain-withkey",
): Promise<string> {
try {
if (key == null) {
throw new Error("No key to decrypt EncString");
}
this.decryptedValue = await encryptService.decryptToUtf8(this, key, "domain-withkey");
this.decryptedValue = await encryptService.decryptToUtf8(this, key, decryptTrace);
} catch (e) {
this.decryptedValue = DECRYPT_ERROR;
}

View File

@@ -114,7 +114,7 @@ export class EncryptServiceImplementation implements EncryptService {
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
if (!macsEqual) {
this.logMacFailed(
"[Encrypt service] MAC comparison failed. Key or payload has changed. Key type " +
"[Encrypt service] decryptToUtf8 MAC comparison failed. Key or payload has changed. Key type " +
encryptionTypeName(key.encType) +
"Payload type " +
encryptionTypeName(encString.encryptionType) +
@@ -128,7 +128,11 @@ export class EncryptServiceImplementation implements EncryptService {
return await this.cryptoFunctionService.aesDecryptFast(fastParams, "cbc");
}
async decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise<Uint8Array> {
async decryptToBytes(
encThing: Encrypted,
key: SymmetricCryptoKey,
decryptContext: string = "no context",
): Promise<Uint8Array> {
if (key == null) {
throw new Error("No encryption key provided.");
}
@@ -145,7 +149,9 @@ export class EncryptServiceImplementation implements EncryptService {
"[Encrypt service] Key has mac key but payload is missing mac bytes. Key type " +
encryptionTypeName(key.encType) +
" Payload type " +
encryptionTypeName(encThing.encryptionType),
encryptionTypeName(encThing.encryptionType) +
" Decrypt context: " +
decryptContext,
);
return null;
}
@@ -155,7 +161,9 @@ export class EncryptServiceImplementation implements EncryptService {
"[Encrypt service] Key encryption type does not match payload encryption type. Key type " +
encryptionTypeName(key.encType) +
" Payload type " +
encryptionTypeName(encThing.encryptionType),
encryptionTypeName(encThing.encryptionType) +
" Decrypt context: " +
decryptContext,
);
return null;
}
@@ -167,11 +175,13 @@ export class EncryptServiceImplementation implements EncryptService {
const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256");
if (computedMac === null) {
this.logMacFailed(
"[Encrypt service] Failed to compute MAC." +
"[Encrypt service#decryptToBytes] Failed to compute MAC." +
" Key type " +
encryptionTypeName(key.encType) +
" Payload type " +
encryptionTypeName(encThing.encryptionType),
encryptionTypeName(encThing.encryptionType) +
" Decrypt context: " +
decryptContext,
);
return null;
}
@@ -179,11 +189,13 @@ export class EncryptServiceImplementation implements EncryptService {
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
if (!macsMatch) {
this.logMacFailed(
"[Encrypt service] MAC comparison failed. Key or payload has changed." +
"[Encrypt service#decryptToBytes]: MAC comparison failed. Key or payload has changed." +
" Key type " +
encryptionTypeName(key.encType) +
" Payload type " +
encryptionTypeName(encThing.encryptionType),
encryptionTypeName(encThing.encryptionType) +
" Decrypt context: " +
decryptContext,
);
return null;
}

View File

@@ -227,6 +227,11 @@ export class NotificationsService implements NotificationsServiceAbstraction {
await this.syncService.fullSync(true);
}
break;
case NotificationType.SyncOrganizationCollectionSettingChanged:
if (isAuthenticated) {
await this.syncService.fullSync(true);
}
break;
default:
break;
}

View File

@@ -123,7 +123,12 @@ describe("Send", () => {
const view = await send.decrypt();
expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey");
expect(send.name.decrypt).toHaveBeenNthCalledWith(1, null, "cryptoKey");
expect(send.name.decrypt).toHaveBeenNthCalledWith(
1,
null,
"cryptoKey",
"Property: name; ObjectContext: No Domain Context",
);
expect(view).toMatchObject({
id: "id",

View File

@@ -43,6 +43,12 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
isWebsite = hostnameUri.indexOf("http") === 0 && hostnameUri.indexOf(".") > -1;
}
if (isWebsite && (hostnameUri.endsWith(".onion") || hostnameUri.endsWith(".i2p"))) {
image = null;
fallbackImage = "images/bwi-globe.png";
break;
}
if (showFavicon && isWebsite) {
try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;

View File

@@ -101,7 +101,7 @@ describe("Attachment", () => {
it("uses the provided key without depending on KeyService", async () => {
const providedKey = mock<SymmetricCryptoKey>();
await attachment.decrypt(null, providedKey);
await attachment.decrypt(null, "", providedKey);
expect(keyService.getUserKeyWithLegacySupport).not.toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, providedKey);
@@ -111,7 +111,7 @@ describe("Attachment", () => {
const orgKey = mock<OrgKey>();
keyService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await attachment.decrypt("orgId", null);
await attachment.decrypt("orgId", "", null);
expect(keyService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, orgKey);
@@ -121,7 +121,7 @@ describe("Attachment", () => {
const userKey = mock<UserKey>();
keyService.getUserKeyWithLegacySupport.mockResolvedValue(userKey);
await attachment.decrypt(null, null);
await attachment.decrypt(null, "", null);
expect(keyService.getUserKeyWithLegacySupport).toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, userKey);

View File

@@ -38,7 +38,11 @@ export class Attachment extends Domain {
);
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<AttachmentView> {
async decrypt(
orgId: string,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj(
new AttachmentView(this),
{
@@ -46,6 +50,7 @@ export class Attachment extends Domain {
},
orgId,
encKey,
"DomainType: Attachment; " + context,
);
if (this.key != null) {

View File

@@ -37,7 +37,11 @@ export class Card extends Domain {
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<CardView> {
async decrypt(
orgId: string,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
return this.decryptObj(
new CardView(),
{
@@ -50,6 +54,7 @@ export class Card extends Domain {
},
orgId,
encKey,
"DomainType: Card; " + context,
);
}

View File

@@ -136,7 +136,11 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
const keyBytes = await encryptService.decryptToBytes(this.key, encKey);
const keyBytes = await encryptService.decryptToBytes(
this.key,
encKey,
`Cipher Id: ${this.id}; Content: CipherKey; IsEncryptedByOrgKey: ${this.organizationId != null}`,
);
if (keyBytes == null) {
model.name = "[error: cannot decrypt]";
model.decryptionFailure = true;
@@ -158,19 +162,36 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
switch (this.type) {
case CipherType.Login:
model.login = await this.login.decrypt(this.organizationId, bypassValidation, encKey);
model.login = await this.login.decrypt(
this.organizationId,
bypassValidation,
`Cipher Id: ${this.id}`,
encKey,
);
break;
case CipherType.SecureNote:
model.secureNote = await this.secureNote.decrypt(this.organizationId, encKey);
model.secureNote = await this.secureNote.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
break;
case CipherType.Card:
model.card = await this.card.decrypt(this.organizationId, encKey);
model.card = await this.card.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey);
break;
case CipherType.Identity:
model.identity = await this.identity.decrypt(this.organizationId, encKey);
model.identity = await this.identity.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
break;
case CipherType.SshKey:
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
model.sshKey = await this.sshKey.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
break;
default:
break;
@@ -181,7 +202,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
await this.attachments.reduce((promise, attachment) => {
return promise
.then(() => {
return attachment.decrypt(this.organizationId, encKey);
return attachment.decrypt(this.organizationId, `Cipher Id: ${this.id}`, encKey);
})
.then((decAttachment) => {
attachments.push(decAttachment);

View File

@@ -61,7 +61,11 @@ export class Identity extends Domain {
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<IdentityView> {
decrypt(
orgId: string,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj(
new IdentityView(),
{
@@ -86,6 +90,7 @@ export class Identity extends Domain {
},
orgId,
encKey,
"DomainType: Identity; " + context,
);
}

View File

@@ -33,7 +33,11 @@ export class LoginUri extends Domain {
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<LoginUriView> {
decrypt(
orgId: string,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj(
new LoginUriView(this),
{
@@ -41,6 +45,7 @@ export class LoginUri extends Domain {
},
orgId,
encKey,
context,
);
}

View File

@@ -55,6 +55,7 @@ export class Login extends Domain {
async decrypt(
orgId: string,
bypassValidation: boolean,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj(
@@ -66,6 +67,7 @@ export class Login extends Domain {
},
orgId,
encKey,
`DomainType: Login; ${context}`,
);
if (this.uris != null) {
@@ -76,7 +78,7 @@ export class Login extends Domain {
continue;
}
const uri = await this.uris[i].decrypt(orgId, encKey);
const uri = await this.uris[i].decrypt(orgId, context, encKey);
// URIs are shared remotely after decryption
// we need to validate that the string hasn't been changed by a compromised server
// This validation is tied to the existence of cypher.key for backwards compatibility

View File

@@ -32,6 +32,7 @@ export class Password extends Domain {
},
orgId,
encKey,
"DomainType: PasswordHistory",
);
}

View File

@@ -20,8 +20,12 @@ export class SecureNote extends Domain {
this.type = obj.type;
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SecureNoteView> {
return Promise.resolve(new SecureNoteView(this));
async decrypt(
orgId: string,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SecureNoteView> {
return new SecureNoteView(this);
}
toSecureNoteData(): SecureNoteData {

View File

@@ -32,7 +32,11 @@ export class SshKey extends Domain {
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SshKeyView> {
decrypt(
orgId: string,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
return this.decryptObj(
new SshKeyView(),
{
@@ -42,6 +46,7 @@ export class SshKey extends Domain {
},
orgId,
encKey,
"DomainType: SshKey; " + context,
);
}

View File

@@ -11,6 +11,7 @@ module.exports = {
content: [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/key-management/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
],
safelist: [],

View File

@@ -70,6 +70,9 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
[ClientType.Browser]: "/tabs/current",
};
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
/// Fixes safari autoprompt behavior
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
@Component({
selector: "bit-lock",
templateUrl: "lock.component.html",
@@ -304,7 +307,13 @@ export class LockComponent implements OnInit, OnDestroy {
(await this.biometricService.getShouldAutopromptNow())
) {
await this.biometricService.setShouldAutopromptNow(false);
await this.unlockViaBiometrics();
if (
(await this.biometricStateService.getLastProcessReload()) == null ||
Date.now() - (await this.biometricStateService.getLastProcessReload()).getTime() >
AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY
) {
await this.unlockViaBiometrics();
}
}
}
}

View File

@@ -14,6 +14,7 @@ import {
PROMPT_AUTOMATICALLY,
PROMPT_CANCELLED,
FINGERPRINT_VALIDATED,
LAST_PROCESS_RELOAD,
} from "./biometric.state";
export abstract class BiometricStateService {
@@ -106,6 +107,10 @@ export abstract class BiometricStateService {
*/
abstract setFingerprintValidated(validated: boolean): Promise<void>;
abstract updateLastProcessReload(): Promise<void>;
abstract getLastProcessReload(): Promise<Date>;
abstract logout(userId: UserId): Promise<void>;
}
@@ -117,6 +122,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
private promptCancelledState: GlobalState<Record<UserId, boolean>>;
private promptAutomaticallyState: ActiveUserState<boolean>;
private fingerprintValidatedState: GlobalState<boolean>;
private lastProcessReloadState: GlobalState<Date>;
biometricUnlockEnabled$: Observable<boolean>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
@@ -124,6 +130,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
promptCancelled$: Observable<boolean>;
promptAutomatically$: Observable<boolean>;
fingerprintValidated$: Observable<boolean>;
lastProcessReload$: Observable<Date>;
constructor(private stateProvider: StateProvider) {
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
@@ -159,6 +166,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
this.fingerprintValidatedState = this.stateProvider.getGlobal(FINGERPRINT_VALIDATED);
this.fingerprintValidated$ = this.fingerprintValidatedState.state$.pipe(map(Boolean));
this.lastProcessReloadState = this.stateProvider.getGlobal(LAST_PROCESS_RELOAD);
this.lastProcessReload$ = this.lastProcessReloadState.state$;
}
async setBiometricUnlockEnabled(enabled: boolean): Promise<void> {
@@ -270,6 +280,14 @@ export class DefaultBiometricStateService implements BiometricStateService {
async setFingerprintValidated(validated: boolean): Promise<void> {
await this.fingerprintValidatedState.update(() => validated);
}
async updateLastProcessReload(): Promise<void> {
await this.lastProcessReloadState.update(() => new Date());
}
async getLastProcessReload(): Promise<Date> {
return await firstValueFrom(this.lastProcessReload$);
}
}
function encryptedClientKeyHalfToEncString(

View File

@@ -95,3 +95,14 @@ export const FINGERPRINT_VALIDATED = new KeyDefinition<boolean>(
deserializer: (obj) => obj,
},
);
/**
* Last process reload time
*/
export const LAST_PROCESS_RELOAD = new KeyDefinition<Date>(
BIOMETRIC_SETTINGS_DISK,
"lastProcessReload",
{
deserializer: (obj) => new Date(obj),
},
);

View File

@@ -462,6 +462,7 @@ describe("keyService", () => {
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(
fakeEncryptedUserPrivateKey,
userKey,
"Content: Encrypted Private Key",
);
expect(userPrivateKey).toBe(fakeDecryptedUserPrivateKey);

View File

@@ -382,7 +382,6 @@ export class DefaultKeyService implements KeyServiceAbstraction {
key: org.key,
};
});
return encOrgKeyData;
});
}
@@ -891,6 +890,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return (await this.encryptService.decryptToBytes(
new EncString(encryptedPrivateKey),
key,
"Content: Encrypted Private Key",
)) as UserPrivateKey;
}

View File

@@ -4,12 +4,8 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
CredentialAlgorithm,
EmailAlgorithms,
PasswordAlgorithms,
UsernameAlgorithms,
} from "@bitwarden/generator-core";
import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "..";
/** Reduces policies to a set of available algorithms
* @param policies the policies to reduce