From 733ba46bfc79f375db9fb0e673e26d6890383e04 Mon Sep 17 00:00:00 2001
From: neuronull <9162534+neuronull@users.noreply.github.com>
Date: Fri, 3 Oct 2025 10:51:10 -0600
Subject: [PATCH 01/37] [BEEEP] Use tracing in macOS provider
---
apps/desktop/desktop_native/Cargo.lock | 15 ++++++++++++++-
.../desktop_native/macos_provider/Cargo.toml | 3 ++-
.../desktop_native/macos_provider/src/lib.rs | 18 ++++++++++++++++--
3 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 9020e08362e..640474f03c6 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -1793,13 +1793,14 @@ version = "0.0.0"
dependencies = [
"desktop_core",
"futures",
- "log",
"oslog",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tracing",
+ "tracing-oslog",
+ "tracing-subscriber",
"uniffi",
]
@@ -3445,6 +3446,18 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-oslog"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "tracing-core",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml
index 9f042209b06..97a8b7d545a 100644
--- a/apps/desktop/desktop_native/macos_provider/Cargo.toml
+++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml
@@ -16,12 +16,13 @@ bench = false
[dependencies]
desktop_core = { path = "../core" }
futures = { workspace = true }
-log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
+tracing-oslog = "0.3.0"
+tracing-subscriber = { workspace = true }
uniffi = { workspace = true, features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]
diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs
index ded133bcb54..4da1174131c 100644
--- a/apps/desktop/desktop_native/macos_provider/src/lib.rs
+++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs
@@ -9,6 +9,11 @@ use std::{
use futures::FutureExt;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
+use tracing_subscriber::{
+ filter::{EnvFilter, LevelFilter},
+ layer::SubscriberExt,
+ util::SubscriberInitExt,
+};
uniffi::setup_scaffolding!();
@@ -65,8 +70,17 @@ impl MacOSProviderClient {
#[allow(clippy::unwrap_used)]
#[uniffi::constructor]
pub fn connect() -> Self {
- let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension")
- .level_filter(log::LevelFilter::Trace)
+ let filter = EnvFilter::builder()
+ // Everything logs at `TRACE`
+ .with_default_directive(LevelFilter::TRACE.into())
+ .from_env_lossy();
+
+ tracing_subscriber::registry()
+ .with(filter)
+ .with(tracing_oslog::OsLogger::new(
+ "com.bitwarden.desktop.autofill-extension",
+ "default",
+ ))
.init();
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
From 9c7d794cac7060050cae51cd18e2bd1652413096 Mon Sep 17 00:00:00 2001
From: Stephon Brown
Date: Fri, 10 Oct 2025 12:05:27 -0400
Subject: [PATCH 02/37] Billing/pm 26686/update upgrade account component to be
responsive (#16808)
* fix(billing): Update initialization of payment component
* fix(billing): Add more signal validation
* fix(billing): Add responsiveness to upgrade-account component
---
.../unified-upgrade-dialog.component.html | 2 +-
.../upgrade-account.component.html | 8 ++--
.../upgrade-payment.component.html | 1 +
.../upgrade-payment.component.ts | 37 +++++++++----------
4 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html
index 6cffd818afc..3e7b797f00f 100644
--- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html
+++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html
@@ -1,6 +1,6 @@
@if (step() == PlanSelectionStep) {
-} @else if (step() == PaymentStep && selectedPlan() !== null) {
+} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
@@ -24,10 +24,10 @@
-
+
@if (premiumCardDetails) {
@if (passwordManager) {
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts
index 8aaced5e1fc..81a4c9191a5 100644
--- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts
+++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts
@@ -118,27 +118,24 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
tier: this.selectedPlanId(),
details: planDetails,
};
+ this.passwordManager = {
+ name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
+ cost: this.selectedPlan.details.passwordManager.annualPrice,
+ quantity: 1,
+ cadence: "year",
+ };
+
+ this.upgradeToMessage = this.i18nService.t(
+ this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
+ );
+
+ this.estimatedTax = 0;
+ } else {
+ this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
+ return;
}
});
- if (!this.selectedPlan) {
- this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
- return;
- }
-
- this.passwordManager = {
- name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
- cost: this.selectedPlan.details.passwordManager.annualPrice,
- quantity: 1,
- cadence: "year",
- };
-
- this.upgradeToMessage = this.i18nService.t(
- this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
- );
-
- this.estimatedTax = 0;
-
this.formGroup.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshSalesTax());
@@ -146,7 +143,9 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
ngAfterViewInit(): void {
- this.cartSummaryComponent.isExpanded.set(false);
+ if (this.cartSummaryComponent) {
+ this.cartSummaryComponent.isExpanded.set(false);
+ }
}
protected get isPremiumPlan(): boolean {
From bc48164cb98f00fba68698d0424f86ed7d98138a Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Fri, 10 Oct 2025 11:41:18 -0500
Subject: [PATCH 03/37] remove archive date when soft deleting (#16794)
---
.../src/vault/services/cipher.service.spec.ts | 22 +++++++++++++++++++
.../src/vault/services/cipher.service.ts | 1 +
2 files changed, 23 insertions(+)
diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts
index b3cb43dcc93..9b1d8096fc7 100644
--- a/libs/common/src/vault/services/cipher.service.spec.ts
+++ b/libs/common/src/vault/services/cipher.service.spec.ts
@@ -819,6 +819,28 @@ describe("Cipher Service", () => {
});
});
+ describe("softDelete", () => {
+ it("clears archivedDate when soft deleting", async () => {
+ const cipherId = "cipher-id-1" as CipherId;
+ const archivedCipher = {
+ ...cipherData,
+ id: cipherId,
+ archivedDate: "2024-01-01T12:00:00.000Z",
+ } as CipherData;
+
+ const ciphers = { [cipherId]: archivedCipher } as Record;
+ stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers);
+
+ await cipherService.softDelete(cipherId, mockUserId);
+
+ const result = await firstValueFrom(
+ stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
+ );
+ expect(result[cipherId].archivedDate).toBeNull();
+ expect(result[cipherId].deletedDate).toBeDefined();
+ });
+ });
+
describe("replace (no upsert)", () => {
// In order to set up initial state we need to manually update the encrypted state
// which will result in an emission. All tests will have this baseline emission.
diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts
index ced8f2bb796..f0e2f9f9404 100644
--- a/libs/common/src/vault/services/cipher.service.ts
+++ b/libs/common/src/vault/services/cipher.service.ts
@@ -1412,6 +1412,7 @@ export class CipherService implements CipherServiceAbstraction {
return;
}
ciphers[cipherId].deletedDate = new Date().toISOString();
+ ciphers[cipherId].archivedDate = null;
};
if (typeof id === "string") {
From 89eb60135f7b32aa26dfe83dffb159875c8b67b0 Mon Sep 17 00:00:00 2001
From: John Harrington <84741727+harr1424@users.noreply.github.com>
Date: Fri, 10 Oct 2025 13:29:12 -0700
Subject: [PATCH 04/37] [PM-24747] Remove chromium importer feature flag
(#16749)
---
libs/common/src/enums/feature-flag.enum.ts | 2 --
.../src/services/import.service.spec.ts | 34 -------------------
libs/importer/src/services/import.service.ts | 15 ++++----
3 files changed, 6 insertions(+), 45 deletions(-)
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 0897aab33c9..8b73010daf5 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -35,7 +35,6 @@ export enum FeatureFlag {
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
- UseChromiumImporter = "pm-23982-chromium-importer",
/* DIRT */
EventBasedOrganizationIntegrations = "event-based-organization-integrations",
@@ -81,7 +80,6 @@ export const DefaultFeatureFlagValue = {
/* Tools */
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
- [FeatureFlag.UseChromiumImporter]: FALSE,
/* DIRT */
[FeatureFlag.EventBasedOrganizationIntegrations]: FALSE,
diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts
index c3d555af936..2a3963e19d7 100644
--- a/libs/importer/src/services/import.service.spec.ts
+++ b/libs/importer/src/services/import.service.spec.ts
@@ -341,19 +341,6 @@ describe("ImportService", () => {
expect(result.loaders).toContain(Loader.file);
});
- it("should exclude chromium loader when feature flag is disabled", async () => {
- const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
- featureFlagSubject.next(false);
-
- const metadataPromise = firstValueFrom(importService.metadata$(typeSubject));
- typeSubject.next(testType);
-
- const result = await metadataPromise;
-
- expect(result.loaders).not.toContain(Loader.chromium);
- expect(result.loaders).toContain(Loader.file);
- });
-
it("should update when type$ changes", async () => {
const emissions: ImporterMetadata[] = [];
const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
@@ -373,27 +360,6 @@ describe("ImportService", () => {
subscription.unsubscribe();
});
- it("should update when feature flag changes", async () => {
- const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader
- const emissions: ImporterMetadata[] = [];
-
- const subscription = importService.metadata$(typeSubject).subscribe((metadata) => {
- emissions.push(metadata);
- });
-
- typeSubject.next(testType);
- featureFlagSubject.next(true);
-
- // Wait for emissions
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- expect(emissions).toHaveLength(2);
- expect(emissions[0].loaders).not.toContain(Loader.chromium);
- expect(emissions[1].loaders).toContain(Loader.chromium);
-
- subscription.unsubscribe();
- });
-
it("should update when both type$ and feature flag change", async () => {
const emissions: ImporterMetadata[] = [];
diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts
index 351d89be3fa..4050ae9fb4b 100644
--- a/libs/importer/src/services/import.service.ts
+++ b/libs/importer/src/services/import.service.ts
@@ -11,7 +11,6 @@ import {
} from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceType } from "@bitwarden/common/enums";
-import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request";
@@ -138,18 +137,16 @@ export class ImportService implements ImportServiceAbstraction {
}
metadata$(type$: Observable): Observable {
- const browserEnabled$ = this.system.configService.getFeatureFlag$(
- FeatureFlag.UseChromiumImporter,
- );
const client = this.system.environment.getClientType();
- const capabilities$ = combineLatest([type$, browserEnabled$]).pipe(
- map(([type, enabled]) => {
+ const capabilities$ = combineLatest([type$]).pipe(
+ map(([type]) => {
let loaders = availableLoaders(type, client);
// Mac App Store is currently disabled due to sandboxing.
let isUnsupported = this.system.environment.isMacAppStore();
- if (enabled && type === "bravecsv") {
+ // disable the chromium loader for Brave on Windows only
+ if (type === "bravecsv") {
try {
const device = this.system.environment.getDevice();
const isWindowsDesktop = device === DeviceType.WindowsDesktop;
@@ -160,8 +157,8 @@ export class ImportService implements ImportServiceAbstraction {
isUnsupported = true;
}
}
- // If the feature flag is disabled, or if the browser is unsupported, remove the chromium loader
- if (!enabled || isUnsupported) {
+ // If the browser is unsupported, remove the chromium loader
+ if (isUnsupported) {
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
}
From cc8bd71775c8131e4529e90e78c61b25c77ecba5 Mon Sep 17 00:00:00 2001
From: Bernd Schoolmann
Date: Fri, 10 Oct 2025 23:04:47 +0200
Subject: [PATCH 05/37] [PM-21033/PM-22863] User Encryption v2 (#14942)
* Add new encrypt service functions
* Undo changes
* Cleanup
* Fix build
* Fix comments
* Switch encrypt service to use SDK functions
* Move remaining functions to PureCrypto
* Tests
* Increase test coverage
* Split up userkey rotation v2 and add tests
* Fix eslint
* Fix type errors
* Fix tests
* Implement signing keys
* Fix sdk init
* Remove key rotation v2 flag
* Fix parsing when user does not have signing keys
* Clear up trusted key naming
* Split up getNewAccountKeys
* Add trim and lowercase
* Replace user.email with masterKeySalt
* Add wasTrustDenied to verifyTrust in key rotation service
* Move testable userkey rotation service code to testable class
* Fix build
* Add comments
* Undo changes
* Fix incorrect behavior on aborting key rotation and fix import
* Fix tests
* Make members of userkey rotation service protected
* Fix type error
* Cleanup and add injectable annotation
* Fix tests
* Update apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
* Remove v1 rotation request
* Add upgrade to user encryption v2
* Fix types
* Update sdk method calls
* Update request models for new server api for rotation
* Fix build
* Update userkey rotation for new server API
* Update crypto client call for new sdk changes
* Fix rotation with signing keys
* Cargo lock
* Fix userkey rotation service
* Fix types
* Undo changes to feature flag service
* Fix linting
* [PM-22863] Account security state (#15309)
* Add account security state
* Update key rotation
* Rename
* Fix build
* Cleanup
* Further cleanup
* Tests
* Increase test coverage
* Add test
* Increase test coverage
* Fix builds and update sdk
* Fix build
* Fix tests
* Reset changes to encrypt service
* Cleanup
* Add comment
* Cleanup
* Cleanup
* Rename model
* Cleanup
* Fix build
* Clean up
* Fix types
* Cleanup
* Cleanup
* Cleanup
* Add test
* Simplify request model
* Rename and add comments
* Fix tests
* Update responses to use less strict typing
* Fix response parsing for v1 users
* Update libs/common/src/key-management/keys/response/private-keys.response.ts
Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
* Update libs/common/src/key-management/keys/response/private-keys.response.ts
Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
* Fix build
* Fix build
* Fix build
* Undo change
* Fix attachments not encrypting for v2 users
---------
Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
---
.../browser/src/background/main.background.ts | 8 +-
.../service-container/service-container.ts | 7 +
.../settings/account/profile.component.ts | 4 +-
...c-key-encryption-key-pair-request.model.ts | 19 +
...ignature-key-pair-request-request.model.ts | 18 +
.../request/account-keys.request.ts | 74 +-
.../types/v1-cryptographic-state.ts | 10 +
.../types/v2-cryptographic-state.ts | 49 ++
.../user-key-rotation.service.spec.ts | 649 ++++++++++++++----
.../key-rotation/user-key-rotation.service.ts | 449 ++++++++----
.../src/services/jslib-services.module.ts | 16 +
.../services/web-crypto-function.service.ts | 3 +-
.../enums/signing-key-type.enum.ts | 13 +
.../keys/response/private-keys.response.ts | 55 ++
...public-key-encryption-key-pair.response.ts | 32 +
.../keys/response/public-keys.response.ts | 44 ++
.../response/signature-key-pair.response.ts | 22 +
.../key-api-service.abstraction.ts | 5 +
.../default-key-api-service.service.ts | 15 +
.../abstractions/security-state.service.ts | 21 +
.../request/security-state.request.ts | 8 +
.../response/security-state.response.ts | 16 +
.../services/security-state.service.ts | 26 +
.../state/security-state.state.ts | 12 +
libs/common/src/key-management/types.ts | 30 +
.../src/models/response/profile.response.ts | 11 +
.../services/key-state/user-key.state.ts | 10 +
.../services/sdk/default-sdk.service.spec.ts | 8 +-
.../services/sdk/default-sdk.service.ts | 89 ++-
.../sync/default-sync.service.spec.ts | 141 ++++
.../src/platform/sync/default-sync.service.ts | 25 +-
libs/common/src/types/key.ts | 3 +-
.../src/abstractions/key.service.ts | 23 +-
libs/key-management/src/key.service.spec.ts | 52 +-
libs/key-management/src/key.service.ts | 42 +-
.../services/node-crypto-function.service.ts | 11 +-
36 files changed, 1693 insertions(+), 327 deletions(-)
create mode 100644 apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts
create mode 100644 apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts
create mode 100644 apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts
create mode 100644 apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts
create mode 100644 libs/common/src/key-management/enums/signing-key-type.enum.ts
create mode 100644 libs/common/src/key-management/keys/response/private-keys.response.ts
create mode 100644 libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts
create mode 100644 libs/common/src/key-management/keys/response/public-keys.response.ts
create mode 100644 libs/common/src/key-management/keys/response/signature-key-pair.response.ts
create mode 100644 libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts
create mode 100644 libs/common/src/key-management/keys/services/default-key-api-service.service.ts
create mode 100644 libs/common/src/key-management/security-state/abstractions/security-state.service.ts
create mode 100644 libs/common/src/key-management/security-state/request/security-state.request.ts
create mode 100644 libs/common/src/key-management/security-state/response/security-state.response.ts
create mode 100644 libs/common/src/key-management/security-state/services/security-state.service.ts
create mode 100644 libs/common/src/key-management/security-state/state/security-state.state.ts
create mode 100644 libs/common/src/key-management/types.ts
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index bd28ddfbbbf..21609432a4b 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
+import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import {
DefaultVaultTimeoutSettingsService,
@@ -452,6 +454,7 @@ export default class MainBackground {
taskService: TaskService;
cipherEncryptionService: CipherEncryptionService;
private restrictedItemTypesService: RestrictedItemTypesService;
+ private securityStateService: SecurityStateService;
ipcContentScriptManagerService: IpcContentScriptManagerService;
ipcService: IpcService;
@@ -668,6 +671,8 @@ export default class MainBackground {
logoutCallback,
);
+ this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
+
this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService(
messageListener,
this.globalStateProvider,
@@ -830,6 +835,7 @@ export default class MainBackground {
this.accountService,
this.kdfConfigService,
this.keyService,
+ this.securityStateService,
this.apiService,
this.stateProvider,
this.configService,
@@ -999,7 +1005,6 @@ export default class MainBackground {
this.avatarService = new AvatarService(this.apiService, this.stateProvider);
this.providerService = new ProviderService(this.stateProvider);
-
this.syncService = new DefaultSyncService(
this.masterPasswordService,
this.accountService,
@@ -1025,6 +1030,7 @@ export default class MainBackground {
this.tokenService,
this.authService,
this.stateProvider,
+ this.securityStateService,
);
this.syncServiceListener = new SyncServiceListener(
diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts
index c2fffef6685..d13d251bce0 100644
--- a/apps/cli/src/service-container/service-container.ts
+++ b/apps/cli/src/service-container/service-container.ts
@@ -73,6 +73,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
+import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@@ -305,6 +307,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
+ securityStateService: SecurityStateService;
cipherArchiveService: CipherArchiveService;
constructor() {
@@ -406,6 +409,8 @@ export class ServiceContainer {
this.derivedStateProvider,
);
+ this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
+
this.environmentService = new DefaultEnvironmentService(
this.stateProvider,
this.accountService,
@@ -612,6 +617,7 @@ export class ServiceContainer {
this.accountService,
this.kdfConfigService,
this.keyService,
+ this.securityStateService,
this.apiService,
this.stateProvider,
this.configService,
@@ -818,6 +824,7 @@ export class ServiceContainer {
this.tokenService,
this.authService,
this.stateProvider,
+ this.securityStateService,
);
this.totpService = new TotpService(this.sdkService);
diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts
index 54f9ac58291..1f4fa578491 100644
--- a/apps/web/src/app/auth/settings/account/profile.component.ts
+++ b/apps/web/src/app/auth/settings/account/profile.component.ts
@@ -56,7 +56,9 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.profile = await this.apiService.getProfile();
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.fingerprintMaterial = userId;
- const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId));
+ const publicKey = (await firstValueFrom(
+ this.keyService.userPublicKey$(userId),
+ )) as UserPublicKey;
if (publicKey == null) {
this.logService.error(
"[ProfileComponent] No public key available for the user: " +
diff --git a/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts
new file mode 100644
index 00000000000..7504b599e16
--- /dev/null
+++ b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts
@@ -0,0 +1,19 @@
+import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { SignedPublicKey } from "@bitwarden/sdk-internal";
+
+export class PublicKeyEncryptionKeyPairRequestModel {
+ wrappedPrivateKey: WrappedPrivateKey;
+ publicKey: string;
+ signedPublicKey: SignedPublicKey | null;
+
+ constructor(
+ wrappedPrivateKey: WrappedPrivateKey,
+ publicKey: UnsignedPublicKey,
+ signedPublicKey: SignedPublicKey | null,
+ ) {
+ this.wrappedPrivateKey = wrappedPrivateKey;
+ this.publicKey = Utils.fromBufferToB64(publicKey);
+ this.signedPublicKey = signedPublicKey;
+ }
+}
diff --git a/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts
new file mode 100644
index 00000000000..2dbf75e2ff8
--- /dev/null
+++ b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts
@@ -0,0 +1,18 @@
+import { VerifyingKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
+import { SignatureAlgorithm } from "@bitwarden/sdk-internal";
+
+export class SignatureKeyPairRequestModel {
+ signatureAlgorithm: SignatureAlgorithm;
+ wrappedSigningKey: WrappedSigningKey;
+ verifyingKey: VerifyingKey;
+
+ constructor(
+ signingKey: WrappedSigningKey,
+ verifyingKey: VerifyingKey,
+ signingKeyAlgorithm: SignatureAlgorithm,
+ ) {
+ this.signatureAlgorithm = signingKeyAlgorithm;
+ this.wrappedSigningKey = signingKey;
+ this.verifyingKey = verifyingKey;
+ }
+}
diff --git a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts
index 1c9b6c9ceca..2c8964a3588 100644
--- a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts
+++ b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts
@@ -1,10 +1,70 @@
-export class AccountKeysRequest {
- // Other keys encrypted by the userkey
- userKeyEncryptedAccountPrivateKey: string;
- accountPublicKey: string;
+import { SecurityStateRequest } from "@bitwarden/common/key-management/security-state/request/security-state.request";
+import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
+import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { PureCrypto } from "@bitwarden/sdk-internal";
- constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) {
- this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey;
- this.accountPublicKey = accountPublicKey;
+import { PublicKeyEncryptionKeyPairRequestModel } from "../model/public-key-encryption-key-pair-request.model";
+import { SignatureKeyPairRequestModel } from "../model/signature-key-pair-request-request.model";
+import { V1UserCryptographicState } from "../types/v1-cryptographic-state";
+import { V2UserCryptographicState } from "../types/v2-cryptographic-state";
+
+// This request contains other account-owned keys that are encrypted with the user key.
+export class AccountKeysRequest {
+ /**
+ * @deprecated
+ */
+ userKeyEncryptedAccountPrivateKey: WrappedPrivateKey | null = null;
+ /**
+ * @deprecated
+ */
+ accountPublicKey: string | null = null;
+
+ publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairRequestModel | null = null;
+ signatureKeyPair: SignatureKeyPairRequestModel | null = null;
+ securityState: SecurityStateRequest | null = null;
+
+ constructor() {}
+
+ static fromV1CryptographicState(state: V1UserCryptographicState): AccountKeysRequest {
+ const request = new AccountKeysRequest();
+ request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey;
+ request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey);
+ request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel(
+ state.publicKeyEncryptionKeyPair.wrappedPrivateKey,
+ state.publicKeyEncryptionKeyPair.publicKey,
+ null,
+ );
+
+ return request;
+ }
+
+ static async fromV2CryptographicState(
+ state: V2UserCryptographicState,
+ ): Promise {
+ // Ensure the SDK is loaded, since it is used to derive the signature algorithm.
+ await SdkLoadService.Ready;
+
+ const request = new AccountKeysRequest();
+ request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey!;
+ request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey);
+ request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel(
+ state.publicKeyEncryptionKeyPair.wrappedPrivateKey,
+ state.publicKeyEncryptionKeyPair.publicKey,
+ state.publicKeyEncryptionKeyPair.signedPublicKey,
+ );
+ request.signatureKeyPair = new SignatureKeyPairRequestModel(
+ state.signatureKeyPair.wrappedSigningKey,
+ state.signatureKeyPair.verifyingKey,
+ PureCrypto.key_algorithm_for_verifying_key(
+ Utils.fromB64ToArray(state.signatureKeyPair.verifyingKey),
+ ),
+ );
+ request.securityState = new SecurityStateRequest(
+ state.securityState.securityState,
+ state.securityState.securityStateVersion,
+ );
+
+ return request;
}
}
diff --git a/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts
new file mode 100644
index 00000000000..220bdd37752
--- /dev/null
+++ b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts
@@ -0,0 +1,10 @@
+import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types";
+import { UserKey } from "@bitwarden/common/types/key";
+
+export type V1UserCryptographicState = {
+ userKey: UserKey;
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: WrappedPrivateKey;
+ publicKey: UnsignedPublicKey;
+ };
+};
diff --git a/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts
new file mode 100644
index 00000000000..da52d6d6eef
--- /dev/null
+++ b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts
@@ -0,0 +1,49 @@
+import {
+ SignedSecurityState,
+ UnsignedPublicKey,
+ VerifyingKey,
+ WrappedPrivateKey,
+ WrappedSigningKey,
+} from "@bitwarden/common/key-management/types";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
+import { UserKey } from "@bitwarden/common/types/key";
+import { SignedPublicKey, UserCryptoV2KeysResponse } from "@bitwarden/sdk-internal";
+
+export type V2UserCryptographicState = {
+ userKey: UserKey;
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: WrappedPrivateKey;
+ publicKey: UnsignedPublicKey;
+ signedPublicKey: SignedPublicKey;
+ };
+ signatureKeyPair: {
+ wrappedSigningKey: WrappedSigningKey;
+ verifyingKey: VerifyingKey;
+ };
+ securityState: {
+ securityState: SignedSecurityState;
+ securityStateVersion: number;
+ };
+};
+
+export function fromSdkV2KeysToV2UserCryptographicState(
+ response: UserCryptoV2KeysResponse,
+): V2UserCryptographicState {
+ return {
+ userKey: SymmetricCryptoKey.fromString(response.userKey) as UserKey,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: response.privateKey as WrappedPrivateKey,
+ publicKey: Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey,
+ signedPublicKey: response.signedPublicKey,
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: response.signingKey as WrappedSigningKey,
+ verifyingKey: response.verifyingKey as VerifyingKey,
+ },
+ securityState: {
+ securityState: response.securityState as SignedSecurityState,
+ securityStateVersion: response.securityVersion,
+ },
+ };
+}
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
index 0ffa48048f6..b790fb8409a 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
@@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs";
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
@@ -11,10 +12,22 @@ import {
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
+import {
+ SignedPublicKey,
+ SignedSecurityState,
+ UnsignedPublicKey,
+ VerifyingKey,
+ WrappedPrivateKey,
+ WrappedSigningKey,
+} from "@bitwarden/common/key-management/types";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
+import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
+import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
@@ -33,13 +46,14 @@ import {
PBKDF2KdfConfig,
KdfConfigService,
KdfConfig,
+ KdfType,
} from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
-import { PureCrypto } from "@bitwarden/sdk-internal";
+import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth";
@@ -48,11 +62,18 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme
import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type";
import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request";
+import { AccountKeysRequest } from "./request/account-keys.request";
import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request";
import { UnlockDataRequest } from "./request/unlock-data.request";
import { UserDataRequest } from "./request/userdata.request";
+import { V1UserCryptographicState } from "./types/v1-cryptographic-state";
+import { V2UserCryptographicState } from "./types/v2-cryptographic-state";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
-import { UserKeyRotationService } from "./user-key-rotation.service";
+import {
+ UserKeyRotationService,
+ V1CryptographicStateParameters,
+ V2CryptographicStateParameters,
+} from "./user-key-rotation.service";
const initialPromptedOpenTrue = jest.fn();
initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) });
@@ -120,6 +141,21 @@ function createMockWebauthn(id: string): any {
} as WebauthnRotateCredentialRequest;
}
+const TEST_VECTOR_USER_KEY_V1 = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+const TEST_VECTOR_PRIVATE_KEY_V1 =
+ "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey;
+const TEST_VECTOR_PUBLIC_KEY_V1 = Utils.fromBufferToB64(new Uint8Array(400));
+const TEST_VECTOR_PRIVATE_KEY_V1_ROTATED =
+ "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|AAAAff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey;
+
+const TEST_VECTOR_USER_KEY_V2 = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
+const TEST_VECTOR_PRIVATE_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedPrivateKey;
+const TEST_VECTOR_SIGNING_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedSigningKey;
+const TEST_VECTOR_VERIFYING_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as VerifyingKey;
+const TEST_VECTOR_SECURITY_STATE_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedSecurityState;
+const TEST_VECTOR_PUBLIC_KEY_V2 = Utils.fromBufferToB64(new Uint8Array(400));
+const TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedPublicKey;
+
class TestUserKeyRotationService extends UserKeyRotationService {
override rotateUserKeyMasterPasswordAndEncryptedData(
currentMasterPassword: string,
@@ -138,22 +174,17 @@ class TestUserKeyRotationService extends UserKeyRotationService {
return super.ensureIsAllowedToRotateUserKey();
}
override getNewAccountKeysV1(
- currentUserKey: UserKey,
- currentUserKeyWrappedPrivateKey: EncString,
- ): Promise<{
- userKey: UserKey;
- asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
- }> {
- return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey);
+ cryptographicStateParameters: V1CryptographicStateParameters,
+ ): Promise {
+ return super.getNewAccountKeysV1(cryptographicStateParameters);
}
override getNewAccountKeysV2(
- currentUserKey: UserKey,
- currentUserKeyWrappedPrivateKey: EncString,
- ): Promise<{
- userKey: UserKey;
- asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
- }> {
- return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey);
+ userId: UserId,
+ kdfConfig: KdfConfig,
+ email: string,
+ cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
+ ): Promise {
+ return super.getNewAccountKeysV2(userId, kdfConfig, email, cryptographicStateParameters);
}
override createMasterPasswordUnlockDataRequest(
userKey: UserKey,
@@ -176,8 +207,8 @@ class TestUserKeyRotationService extends UserKeyRotationService {
masterKeyKdfConfig: KdfConfig;
masterPasswordHint: string;
},
- trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
- trustedOrganizationPublicKeys: Uint8Array[],
+ trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[],
+ trustedOrganizationPublicKeys: UnsignedPublicKey[],
): Promise {
return super.getAccountUnlockDataRequest(
userId,
@@ -190,8 +221,8 @@ class TestUserKeyRotationService extends UserKeyRotationService {
}
override verifyTrust(user: Account): Promise<{
wasTrustDenied: boolean;
- trustedOrganizationPublicKeys: Uint8Array[];
- trustedEmergencyAccessUserPublicKeys: Uint8Array[];
+ trustedOrganizationPublicKeys: UnsignedPublicKey[];
+ trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[];
}> {
return super.verifyTrust(user);
}
@@ -202,14 +233,6 @@ class TestUserKeyRotationService extends UserKeyRotationService {
): Promise {
return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user);
}
- override makeNewUserKeyV1(oldUserKey: UserKey): Promise {
- return super.makeNewUserKeyV1(oldUserKey);
- }
- override makeNewUserKeyV2(
- oldUserKey: UserKey,
- ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
- return super.makeNewUserKeyV2(oldUserKey);
- }
override isV1User(userKey: UserKey): boolean {
return super.isV1User(userKey);
}
@@ -227,6 +250,13 @@ class TestUserKeyRotationService extends UserKeyRotationService {
masterKeySalt,
);
}
+ override getCryptographicStateForUser(user: Account): Promise<{
+ masterKeyKdfConfig: KdfConfig;
+ masterKeySalt: string;
+ cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters;
+ }> {
+ return super.getCryptographicStateForUser(user);
+ }
}
describe("KeyRotationService", () => {
@@ -251,6 +281,8 @@ describe("KeyRotationService", () => {
let mockI18nService: MockProxy;
let mockCryptoFunctionService: MockProxy;
let mockKdfConfigService: MockProxy;
+ let mockSdkClientFactory: MockProxy;
+ let mockSecurityStateService: MockProxy;
const mockUser = {
id: "mockUserId" as UserId,
@@ -261,6 +293,9 @@ describe("KeyRotationService", () => {
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
+ const mockMakeKeysForUserCryptoV2 = jest.fn();
+ const mockGetV2RotatedAccountKeys = jest.fn();
+
beforeAll(() => {
mockApiService = mock();
mockCipherService = mock();
@@ -271,7 +306,7 @@ describe("KeyRotationService", () => {
mockTrustedPublicKeys.map((key) => {
return {
publicKey: key,
- id: "mockId",
+ id: "00000000-0000-0000-0000-000000000000" as UserId,
granteeId: "mockGranteeId",
name: "mockName",
email: "mockEmail",
@@ -306,6 +341,17 @@ describe("KeyRotationService", () => {
mockDialogService = mock();
mockCryptoFunctionService = mock();
mockKdfConfigService = mock();
+ mockSdkClientFactory = mock();
+ mockSdkClientFactory.createSdkClient.mockResolvedValue({
+ crypto: () => {
+ return {
+ initialize_user_crypto: jest.fn(),
+ make_keys_for_user_crypto_v2: mockMakeKeysForUserCryptoV2,
+ get_v2_rotated_account_keys: mockGetV2RotatedAccountKeys,
+ } as any;
+ },
+ } as BitwardenClient);
+ mockSecurityStateService = mock();
keyRotationService = new TestUserKeyRotationService(
mockApiService,
@@ -327,6 +373,8 @@ describe("KeyRotationService", () => {
mockConfigService,
mockCryptoFunctionService,
mockKdfConfigService,
+ mockSdkClientFactory,
+ mockSecurityStateService,
);
});
@@ -334,13 +382,16 @@ describe("KeyRotationService", () => {
jest.clearAllMocks();
jest.mock("@bitwarden/key-management-ui");
jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64));
- jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70));
jest
.spyOn(PureCrypto, "encrypt_user_key_with_master_password")
.mockReturnValue("mockNewUserKey");
+ Object.defineProperty(SdkLoadService, "Ready", {
+ value: Promise.resolve(),
+ configurable: true,
+ });
});
- describe("rotateUserKeyAndEncryptedData", () => {
+ describe("rotateUserKeyMasterPasswordAndEncryptedData", () => {
let privateKey: BehaviorSubject;
let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
@@ -438,6 +489,64 @@ describe("KeyRotationService", () => {
expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
});
+ it("passes the EnrollAeadOnKeyRotation feature flag to getRotatedAccountKeysFlagged", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ mockKdfConfigService.getKdfConfig$.mockReturnValue(
+ new BehaviorSubject(new PBKDF2KdfConfig(100000)),
+ );
+ mockKeyService.userKey$.mockReturnValue(
+ new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
+ );
+ mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
+ new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString),
+ );
+ mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
+ mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
+ mockConfigService.getFeatureFlag.mockResolvedValue(true);
+
+ const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ accountKeysRequest: {
+ userKeyEncryptedAccountPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ accountPublicKey: TEST_VECTOR_PUBLIC_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: TEST_VECTOR_PUBLIC_KEY_V2,
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
+ signatureAlgorithm: "ed25519",
+ },
+ securityState: {
+ securityState: TEST_VECTOR_SECURITY_STATE_V2,
+ securityVersion: 2,
+ },
+ },
+ });
+
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "mockMasterPassword1",
+ mockUser,
+ "masterPasswordHint",
+ );
+
+ expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
+ FeatureFlag.EnrollAeadOnKeyRotation,
+ );
+ expect(spy).toHaveBeenCalledWith(
+ mockUser.id,
+ expect.any(PBKDF2KdfConfig),
+ mockUser.email,
+ expect.objectContaining({ version: 1 }),
+ true,
+ );
+ });
+
it("throws if kdf config is null", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
@@ -511,17 +620,17 @@ describe("KeyRotationService", () => {
});
describe("getNewAccountKeysV1", () => {
- const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
- const mockEncryptedPrivateKey = new EncString(
- "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
- );
- const mockNewEncryptedPrivateKey = new EncString(
- "2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
- );
+ const currentUserKey = TEST_VECTOR_USER_KEY_V1;
+ const mockEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey;
+ const mockNewEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1_ROTATED as WrappedPrivateKey;
beforeAll(() => {
mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200));
- mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey);
- mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400));
+ mockEncryptService.wrapDecapsulationKey.mockResolvedValue(
+ new EncString(mockNewEncryptedPrivateKey),
+ );
+ mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
+ new Uint8Array(400) as UnsignedPublicKey,
+ );
});
afterAll(() => {
@@ -529,28 +638,110 @@ describe("KeyRotationService", () => {
});
it("returns new account keys", async () => {
- const result = await keyRotationService.getNewAccountKeysV1(
- currentUserKey,
- mockEncryptedPrivateKey,
- );
+ const result = await keyRotationService.getNewAccountKeysV1({
+ version: 1,
+ userKey: currentUserKey,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: mockEncryptedPrivateKey,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
+ },
+ });
expect(result).toEqual({
userKey: expect.any(SymmetricCryptoKey),
- asymmetricEncryptionKeys: {
+ publicKeyEncryptionKeyPair: {
wrappedPrivateKey: mockNewEncryptedPrivateKey,
- publicKey: Utils.fromBufferToB64(new Uint8Array(400)),
+ publicKey: new Uint8Array(400) as UserPublicKey,
},
});
});
});
describe("getNewAccountKeysV2", () => {
- it("throws not supported", async () => {
- await expect(
- keyRotationService.getNewAccountKeysV2(
- new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
- null,
- ),
- ).rejects.toThrow("User encryption v2 upgrade is not supported yet");
+ it("rotates a v2 user", async () => {
+ mockGetV2RotatedAccountKeys.mockReturnValue({
+ userKey: TEST_VECTOR_USER_KEY_V2.toBase64(),
+ privateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: TEST_VECTOR_PUBLIC_KEY_V2,
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ signingKey: TEST_VECTOR_SIGNING_KEY_V2,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
+ securityState: TEST_VECTOR_SECURITY_STATE_V2,
+ securityVersion: 2,
+ });
+ const result = await keyRotationService.getNewAccountKeysV2(
+ "00000000-0000-0000-0000-000000000000" as UserId,
+ new PBKDF2KdfConfig(600_000),
+ "mockuseremail",
+ {
+ version: 2 as const,
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ },
+ signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
+ securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
+ },
+ );
+ expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled();
+ expect(result).toEqual({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
+ },
+ securityState: {
+ securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
+ securityStateVersion: 2,
+ },
+ });
+ });
+ it("upgrades v1 user to v2 user", async () => {
+ mockMakeKeysForUserCryptoV2.mockReturnValue({
+ userKey: TEST_VECTOR_USER_KEY_V2.toBase64(),
+ privateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ signingKey: TEST_VECTOR_SIGNING_KEY_V2,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
+ securityState: TEST_VECTOR_SECURITY_STATE_V2,
+ securityVersion: 2,
+ });
+ const result = await keyRotationService.getNewAccountKeysV2(
+ "00000000-0000-0000-0000-000000000000" as UserId,
+ new PBKDF2KdfConfig(600_000),
+ "mockuseremail",
+ {
+ version: 1,
+ userKey: TEST_VECTOR_USER_KEY_V1,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
+ },
+ },
+ );
+ expect(mockMakeKeysForUserCryptoV2).toHaveBeenCalled();
+ expect(result).toEqual({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2),
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
+ },
+ securityState: {
+ securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
+ securityStateVersion: 2,
+ },
+ });
});
});
@@ -560,7 +751,7 @@ describe("KeyRotationService", () => {
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
);
mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
- const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = TEST_VECTOR_USER_KEY_V1;
const userAccount = mockUser;
const masterPasswordUnlockData =
await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, {
@@ -572,13 +763,13 @@ describe("KeyRotationService", () => {
expect(masterPasswordUnlockData).toEqual({
masterKeyEncryptedUserKey: "mockNewUserKey",
email: "mockEmail",
- kdfType: 0,
+ kdfType: KdfType.PBKDF2_SHA256,
kdfIterations: 600_000,
masterKeyAuthenticationHash: "mockMasterPasswordHash",
masterPasswordHint: "mockMasterPasswordHint",
});
expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith(
- new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(),
+ TEST_VECTOR_USER_KEY_V1.toEncoded(),
"mockMasterPassword",
userAccount.email,
new PBKDF2KdfConfig(600_000).toSdkConfig(),
@@ -637,8 +828,8 @@ describe("KeyRotationService", () => {
masterKeyKdfConfig: new PBKDF2KdfConfig(600_000),
masterPasswordHint: "mockMasterPasswordHint",
},
- [new Uint8Array(1)], // emergency access public key
- [new Uint8Array(2)], // account recovery public key
+ [new Uint8Array(1) as UnsignedPublicKey], // emergency access public key
+ [new Uint8Array(2) as UnsignedPublicKey], // account recovery public key
);
expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([
{
@@ -758,66 +949,29 @@ describe("KeyRotationService", () => {
expect(wasTrustDenied).toBe(true);
});
- it("returns trusted keys if all dialogs are accepted", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
- mockGranteeEmergencyAccessWithPublicKey,
- ]);
- mockResetPasswordService.getPublicKeys.mockResolvedValue([
- mockOrganizationUserResetPasswordEntry,
- ]);
- const {
- wasTrustDenied,
- trustedOrganizationPublicKeys: trustedOrgs,
- trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
- } = await keyRotationService.verifyTrust(mockUser);
- expect(wasTrustDenied).toBe(false);
- expect(trustedEmergencyAccessUsers).toEqual([
- mockGranteeEmergencyAccessWithPublicKey.publicKey,
- ]);
- expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]);
- });
- });
-
- describe("makeNewUserKeyV1", () => {
- it("throws if old keys is xchacha20poly1305 key", async () => {
- await expect(
- keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey),
- ).rejects.toThrow(
- "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
- );
- });
- it("returns new user key", async () => {
- const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
- const newKey = await keyRotationService.makeNewUserKeyV1(oldKey);
- expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
- });
- });
-
- describe("makeNewUserKeyV2", () => {
- it("returns xchacha20poly1305 key", async () => {
- const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
- const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey);
- expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70)));
- });
- it("returns isUpgrading true if old key is v1", async () => {
- const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
- const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
- expect(newKey).toEqual({
- newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
- isUpgrading: true,
- });
- });
- it("returns isUpgrading false if old key is v2", async () => {
- const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
- const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
- expect(newKey).toEqual({
- newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
- isUpgrading: false,
- });
- });
+ test.each([
+ [[mockGranteeEmergencyAccessWithPublicKey], []],
+ [[], [mockOrganizationUserResetPasswordEntry]],
+ [[], []],
+ [[mockGranteeEmergencyAccessWithPublicKey], [mockOrganizationUserResetPasswordEntry]],
+ ])(
+ "returns trusted keys when dialogs are open and public keys are provided",
+ async (emUsers, orgs) => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue(emUsers);
+ mockResetPasswordService.getPublicKeys.mockResolvedValue(orgs);
+ const {
+ wasTrustDenied,
+ trustedOrganizationPublicKeys: trustedOrgs,
+ trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
+ } = await keyRotationService.verifyTrust(mockUser);
+ expect(wasTrustDenied).toBe(false);
+ expect(trustedEmergencyAccessUsers).toEqual(emUsers.map((e) => e.publicKey));
+ expect(trustedOrgs).toEqual(orgs.map((o) => o.publicKey));
+ },
+ );
});
describe("getAccountDataRequest", () => {
@@ -890,13 +1044,264 @@ describe("KeyRotationService", () => {
});
describe("isV1UserKey", () => {
- const v1Key = new SymmetricCryptoKey(new Uint8Array(64));
- const v2Key = new SymmetricCryptoKey(new Uint8Array(70));
+ const aes256CbcHmacV1UserKey = new SymmetricCryptoKey(new Uint8Array(64));
+ const coseV2UserKey = new SymmetricCryptoKey(new Uint8Array(70));
it("returns true for v1 key", () => {
- expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true);
+ expect(keyRotationService.isV1User(aes256CbcHmacV1UserKey as UserKey)).toBe(true);
});
it("returns false for v2 key", () => {
- expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false);
+ expect(keyRotationService.isV1User(coseV2UserKey as UserKey)).toBe(false);
+ });
+ it("returns false for 32 byte AES256-CBC key", () => {
+ const aes256CbcKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
+ expect(keyRotationService.isV1User(aes256CbcKey)).toBe(false);
+ });
+ });
+
+ describe("makeServerMasterKeyAuthenticationHash", () => {
+ it("returns the master key authentication hash", async () => {
+ mockKeyService.makeMasterKey.mockResolvedValue(
+ new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
+ );
+ mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
+ const masterKeyAuthenticationHash =
+ await keyRotationService.makeServerMasterKeyAuthenticationHash(
+ "mockMasterPassword",
+ new PBKDF2KdfConfig(600_000),
+ "mockEmail",
+ );
+ expect(masterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
+ expect(mockKeyService.makeMasterKey).toHaveBeenCalledWith(
+ "mockMasterPassword",
+ "mockEmail",
+ new PBKDF2KdfConfig(600_000),
+ );
+ expect(mockKeyService.hashMasterKey).toHaveBeenCalledWith(
+ "mockMasterPassword",
+ new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
+ HashPurpose.ServerAuthorization,
+ );
+ });
+ });
+
+ describe("getCryptographicStateForUser", () => {
+ beforeEach(() => {
+ mockKdfConfigService.getKdfConfig$.mockReturnValue(
+ new BehaviorSubject(new PBKDF2KdfConfig(100000)),
+ );
+ mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(TEST_VECTOR_USER_KEY_V2));
+ mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
+ new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V2 as string as EncryptedString),
+ );
+ mockKeyService.userSigningKey$.mockReturnValue(
+ new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey),
+ );
+ mockSecurityStateService.accountSecurityState$.mockReturnValue(
+ new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState),
+ );
+ mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
+ new Uint8Array(400) as UnsignedPublicKey,
+ );
+ });
+
+ it("returns the cryptographic state for v1 user", async () => {
+ mockKeyService.userKey$.mockReturnValue(
+ new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
+ );
+ mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
+ new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString),
+ );
+ mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
+ mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
+
+ const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser);
+ expect(cryptographicState).toEqual({
+ masterKeyKdfConfig: new PBKDF2KdfConfig(100000),
+ masterKeySalt: "mockemail", // the email is lowercased to become the salt
+ cryptographicStateParameters: {
+ version: 1,
+ userKey: TEST_VECTOR_USER_KEY_V1,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1),
+ },
+ },
+ });
+ });
+
+ it("returns the cryptographic state for v2 user", async () => {
+ const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser);
+ expect(cryptographicState).toEqual({
+ masterKeyKdfConfig: new PBKDF2KdfConfig(100000),
+ masterKeySalt: "mockemail", // the email is lowercased to become the salt
+ cryptographicStateParameters: {
+ version: 2,
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ },
+ signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
+ securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
+ },
+ });
+ });
+
+ it("throws if no kdf config is found", async () => {
+ mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null));
+ await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
+ "Failed to get KDF config",
+ );
+ });
+
+ it("throws if current user key is not found", async () => {
+ mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(null));
+ await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
+ "Failed to get User key",
+ );
+ });
+
+ it("throws if private key is not found", async () => {
+ mockKeyService.userEncryptedPrivateKey$.mockReturnValue(new BehaviorSubject(null));
+ await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
+ "Failed to get Private key",
+ );
+ });
+
+ it("throws if user key is not AES256-CBC-HMAC or COSE", async () => {
+ const invalidKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
+ mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(invalidKey));
+ await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
+ "Unsupported user key type",
+ );
+ });
+ });
+
+ describe("getRotatedAccountKeysFlagged", () => {
+ const userId = "mockUserId" as UserId;
+ const kdfConfig = new PBKDF2KdfConfig(100000);
+ const masterKeySalt = "mockSalt";
+ const v1Params = {
+ version: 1,
+ userKey: TEST_VECTOR_USER_KEY_V1,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
+ },
+ } as V1CryptographicStateParameters;
+ const v2Params = {
+ version: 2,
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ },
+ signingKey: TEST_VECTOR_SIGNING_KEY_V2,
+ securityState: TEST_VECTOR_SECURITY_STATE_V2,
+ } as V2CryptographicStateParameters;
+
+ beforeEach(() => {
+ jest.spyOn(keyRotationService, "getNewAccountKeysV1").mockResolvedValue({
+ userKey: TEST_VECTOR_USER_KEY_V1,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1_ROTATED,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
+ },
+ });
+ jest.spyOn(keyRotationService, "getNewAccountKeysV2").mockResolvedValue({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
+ publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
+ signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
+ verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
+ },
+ securityState: {
+ securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
+ securityStateVersion: 2,
+ },
+ });
+ jest
+ .spyOn(AccountKeysRequest, "fromV1CryptographicState")
+ .mockReturnValue("v1Request" as any);
+ jest
+ .spyOn(AccountKeysRequest, "fromV2CryptographicState")
+ .mockResolvedValue("v2Request" as any);
+ });
+
+ it("returns v2 keys and request if v2UpgradeEnabled is true", async () => {
+ const result = await keyRotationService.getRotatedAccountKeysFlagged(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ v1Params,
+ true,
+ );
+ expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ v1Params,
+ );
+ expect(result).toEqual({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ accountKeysRequest: "v2Request",
+ });
+ });
+
+ it("returns v2 keys and request if params.version is 2", async () => {
+ const result = await keyRotationService.getRotatedAccountKeysFlagged(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ v2Params,
+ false,
+ );
+ expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ v2Params,
+ );
+ expect(result).toEqual({
+ userKey: TEST_VECTOR_USER_KEY_V2,
+ accountKeysRequest: "v2Request",
+ });
+ });
+
+ it("returns v1 keys and request if v2UpgradeEnabled is false and params.version is 1", async () => {
+ const result = await keyRotationService.getRotatedAccountKeysFlagged(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ v1Params,
+ false,
+ );
+ expect(keyRotationService.getNewAccountKeysV1).toHaveBeenCalledWith(v1Params);
+ expect(result).toEqual({
+ userKey: TEST_VECTOR_USER_KEY_V1,
+ accountKeysRequest: "v1Request",
+ });
+ });
+ });
+
+ describe("ensureIsAllowedToRotateUserKey", () => {
+ it("resolves if last sync exists", async () => {
+ mockSyncService.getLastSync.mockResolvedValue(new Date());
+ await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).resolves.toBeUndefined();
+ });
+
+ it("throws if last sync is null", async () => {
+ mockSyncService.getLastSync.mockResolvedValue(null);
+ await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).rejects.toThrow(
+ /de-synced|log out and log back in/i,
+ );
+ expect(mockLogService.info).toHaveBeenCalledWith(
+ "[Userkey rotation] Client was never synced. Aborting!",
+ );
});
});
});
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
index f7f611b75ee..0980beddd09 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
@@ -1,5 +1,5 @@
import { Injectable } from "@angular/core";
-import { firstValueFrom } from "rxjs";
+import { firstValueFrom, Observable } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -7,13 +7,21 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
-import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
+import {
+ SignedSecurityState,
+ UnsignedPublicKey,
+ WrappedPrivateKey,
+ WrappedSigningKey,
+} from "@bitwarden/common/key-management/types";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
+import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
+import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums";
-import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
@@ -28,7 +36,7 @@ import {
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
-import { PureCrypto } from "@bitwarden/sdk-internal";
+import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -39,6 +47,11 @@ import { MasterPasswordUnlockDataRequest } from "./request/master-password-unloc
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
import { UnlockDataRequest } from "./request/unlock-data.request";
import { UserDataRequest } from "./request/userdata.request";
+import { V1UserCryptographicState } from "./types/v1-cryptographic-state";
+import {
+ fromSdkV2KeysToV2UserCryptographicState,
+ V2UserCryptographicState,
+} from "./types/v2-cryptographic-state";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
type MasterPasswordAuthenticationAndUnlockData = {
@@ -48,6 +61,19 @@ type MasterPasswordAuthenticationAndUnlockData = {
masterPasswordHint: string;
};
+/**
+ * A token provider that exposes a null access token to the SDK.
+ */
+class NoopTokenProvider implements TokenProvider {
+ constructor() {}
+
+ async get_access_token(): Promise {
+ // Ignore from the test coverage, since this is called by the SDK
+ /* istanbul ignore next */
+ return undefined;
+ }
+}
+
@Injectable({ providedIn: "root" })
export class UserKeyRotationService {
constructor(
@@ -70,6 +96,8 @@ export class UserKeyRotationService {
private configService: ConfigService,
private cryptoFunctionService: CryptoFunctionService,
private kdfConfigService: KdfConfigService,
+ private sdkClientFactory: SdkClientFactory,
+ private securityStateService: SecurityStateService,
) {}
/**
@@ -85,12 +113,15 @@ export class UserKeyRotationService {
user: Account,
newMasterPasswordHint?: string,
): Promise {
- this.logService.info("[UserKey Rotation] Starting user key rotation...");
+ // Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized.
+ await SdkLoadService.Ready;
const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.EnrollAeadOnKeyRotation,
);
+ this.logService.info("[UserKey Rotation] Starting user key rotation...");
+
// Make sure all conditions match - e.g. account state is up to date
await this.ensureIsAllowedToRotateUserKey();
@@ -104,53 +135,26 @@ export class UserKeyRotationService {
}
// Read current cryptographic state / settings
- const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow(
- this.kdfConfigService.getKdfConfig$(user.id),
- "KDF config",
- ))!;
- // The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased.
- const masterKeySalt = user.email.trim().toLowerCase();
- const currentUserKey: UserKey = (await firstValueFromOrThrow(
- this.keyService.userKey$(user.id),
- "User key",
- ))!;
- const currentUserKeyWrappedPrivateKey = new EncString(
- (await firstValueFromOrThrow(
- this.keyService.userEncryptedPrivateKey$(user.id),
- "User encrypted private key",
- ))!,
- );
+ const {
+ masterKeyKdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters: currentCryptographicStateParameters,
+ } = await this.getCryptographicStateForUser(user);
- // Update account keys
- // This creates at least a new user key, and possibly upgrades user encryption formats
- let newUserKey: UserKey;
- let wrappedPrivateKey: EncString;
- let publicKey: string;
- if (upgradeToV2FeatureFlagEnabled) {
- this.logService.info("[Userkey rotation] Using v2 account keys");
- const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2(
- currentUserKey,
- currentUserKeyWrappedPrivateKey,
- );
- newUserKey = userKey;
- wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
- publicKey = asymmetricEncryptionKeys.publicKey;
- } else {
- this.logService.info("[Userkey rotation] Using v1 account keys");
- const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1(
- currentUserKey,
- currentUserKeyWrappedPrivateKey,
- );
- newUserKey = userKey;
- wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
- publicKey = asymmetricEncryptionKeys.publicKey;
- }
+ // Get new set of keys for the account.
+ const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged(
+ user.id,
+ masterKeyKdfConfig,
+ user.email,
+ currentCryptographicStateParameters,
+ upgradeToV2FeatureFlagEnabled,
+ );
// Assemble the key rotation request
const request = new RotateUserAccountKeysRequest(
await this.getAccountUnlockDataRequest(
user.id,
- currentUserKey,
+ currentCryptographicStateParameters.userKey,
newUserKey,
{
masterPassword: newMasterPassword,
@@ -161,8 +165,12 @@ export class UserKeyRotationService {
trustedEmergencyAccessUserPublicKeys,
trustedOrganizationPublicKeys,
),
- new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey),
- await this.getAccountDataRequest(currentUserKey, newUserKey, user),
+ accountKeysRequest,
+ await this.getAccountDataRequest(
+ currentCryptographicStateParameters.userKey,
+ newUserKey,
+ user,
+ ),
await this.makeServerMasterKeyAuthenticationHash(
currentMasterPassword,
masterKeyKdfConfig,
@@ -194,55 +202,153 @@ export class UserKeyRotationService {
}
}
+ async getRotatedAccountKeysFlagged(
+ userId: UserId,
+ kdfConfig: KdfConfig,
+ masterKeySalt: string,
+ cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
+ v2UpgradeEnabled: boolean,
+ ): Promise<{ userKey: UserKey; accountKeysRequest: AccountKeysRequest }> {
+ if (v2UpgradeEnabled || cryptographicStateParameters.version === 2) {
+ const keys = await this.getNewAccountKeysV2(
+ userId,
+ kdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters,
+ );
+ return {
+ userKey: keys.userKey,
+ accountKeysRequest: await AccountKeysRequest.fromV2CryptographicState(keys),
+ };
+ } else {
+ const keys = await this.getNewAccountKeysV1(
+ cryptographicStateParameters as V1CryptographicStateParameters,
+ );
+ return {
+ userKey: keys.userKey,
+ accountKeysRequest: AccountKeysRequest.fromV1CryptographicState(keys),
+ };
+ }
+ }
+
+ /**
+ * This method rotates the user key of a V1 user and re-encrypts the private key.
+ * @deprecated Removed after roll-out of V2 encryption.
+ */
protected async getNewAccountKeysV1(
- currentUserKey: UserKey,
- currentUserKeyWrappedPrivateKey: EncString,
- ): Promise<{
- userKey: UserKey;
- asymmetricEncryptionKeys: {
- wrappedPrivateKey: EncString;
- publicKey: string;
- };
- }> {
- // Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key.
+ cryptographicStateParameters: V1CryptographicStateParameters,
+ ): Promise {
+ // Account key rotation creates a new user key. All downstream data and keys need to be re-encrypted under this key.
// Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the
// creation of a new signing key pair.
- const newUserKey = await this.makeNewUserKeyV1(currentUserKey);
+ const newUserKey = new SymmetricCryptoKey(
+ PureCrypto.make_user_key_aes256_cbc_hmac(),
+ ) as UserKey;
// Re-encrypt the private key with the new user key
// Rotation of the private key is not supported yet
const privateKey = await this.encryptService.unwrapDecapsulationKey(
- currentUserKeyWrappedPrivateKey,
- currentUserKey,
+ new EncString(cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey),
+ cryptographicStateParameters.userKey,
);
- const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey(
+ const newUserKeyWrappedPrivateKey = (
+ await this.encryptService.wrapDecapsulationKey(privateKey, newUserKey)
+ ).encryptedString! as string as WrappedPrivateKey;
+ const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey(
privateKey,
- newUserKey,
- );
- const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
+ )) as UnsignedPublicKey;
return {
userKey: newUserKey,
- asymmetricEncryptionKeys: {
+ publicKeyEncryptionKeyPair: {
wrappedPrivateKey: newUserKeyWrappedPrivateKey,
- publicKey: Utils.fromBufferToB64(publicKey),
+ publicKey: publicKey,
},
};
}
+ /**
+ * This method either enrolls a user from v1 encryption to v2 encryption, rotating the user key, or rotates the keys of a v2 user, staying on v2.
+ */
protected async getNewAccountKeysV2(
- currentUserKey: UserKey,
- currentUserKeyWrappedPrivateKey: EncString,
- ): Promise<{
- userKey: UserKey;
- asymmetricEncryptionKeys: {
- wrappedPrivateKey: EncString;
- publicKey: string;
- };
- }> {
- throw new Error("User encryption v2 upgrade is not supported yet");
+ userId: UserId,
+ masterKeyKdfConfig: KdfConfig,
+ masterKeySalt: string,
+ cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
+ ): Promise {
+ if (cryptographicStateParameters.version === 1) {
+ return this.upgradeV1UserToV2UserAccountKeys(
+ userId,
+ masterKeyKdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters as V1CryptographicStateParameters,
+ );
+ } else {
+ return this.rotateV2UserAccountKeys(
+ userId,
+ masterKeyKdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters as V2CryptographicStateParameters,
+ );
+ }
}
+ /**
+ * Upgrades a V1 user to a V2 user by creating a new user key, re-encrypting the private key, generating a signature key-pair, and
+ * finally creating a signed security state.
+ */
+ protected async upgradeV1UserToV2UserAccountKeys(
+ userId: UserId,
+ kdfConfig: KdfConfig,
+ email: string,
+ cryptographicStateParameters: V1CryptographicStateParameters,
+ ): Promise {
+ // Initialize an SDK with the current cryptographic state
+ const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider());
+ await sdk.crypto().initialize_user_crypto({
+ userId: asUuid(userId),
+ kdfParams: kdfConfig.toSdkConfig(),
+ email: email,
+ privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
+ signingKey: undefined,
+ securityState: undefined,
+ method: {
+ decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
+ },
+ });
+
+ return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().make_keys_for_user_crypto_v2());
+ }
+
+ /**
+ * Generates a new user key for a v2 user, and re-encrypts the private key, signing key.
+ */
+ protected async rotateV2UserAccountKeys(
+ userId: UserId,
+ kdfConfig: KdfConfig,
+ email: string,
+ cryptographicStateParameters: V2CryptographicStateParameters,
+ ): Promise {
+ // Initialize an SDK with the current cryptographic state
+ const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider());
+ await sdk.crypto().initialize_user_crypto({
+ userId: asUuid(userId),
+ kdfParams: kdfConfig.toSdkConfig(),
+ email: email,
+ privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
+ signingKey: cryptographicStateParameters.signingKey,
+ securityState: cryptographicStateParameters.securityState,
+ method: {
+ decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
+ },
+ });
+
+ return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().get_v2_rotated_account_keys());
+ }
+
+ /**
+ * Generates a new request for updating the master-password unlock/authentication data.
+ */
protected async createMasterPasswordUnlockDataRequest(
userKey: UserKey,
newUnlockData: MasterPasswordAuthenticationAndUnlockData,
@@ -272,13 +378,17 @@ export class UserKeyRotationService {
);
}
+ /**
+ * Re-generates the accounts unlock methods, including master-password, passkey, trusted device, emergency access, and organization account recovery
+ * for the new user key.
+ */
protected async getAccountUnlockDataRequest(
userId: UserId,
currentUserKey: UserKey,
newUserKey: UserKey,
masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData,
- trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
- trustedOrganizationPublicKeys: Uint8Array[],
+ trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[],
+ trustedOrganizationPublicKeys: UnsignedPublicKey[],
): Promise {
// To ensure access; all unlock methods need to be updated and provided the new user key.
// User unlock methods
@@ -321,10 +431,13 @@ export class UserKeyRotationService {
);
}
+ /**
+ * Verifies the trust of the organizations and emergency access users by prompting the user. Denying any of these will return early.
+ */
protected async verifyTrust(user: Account): Promise<{
wasTrustDenied: boolean;
- trustedOrganizationPublicKeys: Uint8Array[];
- trustedEmergencyAccessUserPublicKeys: Uint8Array[];
+ trustedOrganizationPublicKeys: UnsignedPublicKey[];
+ trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[];
}> {
// Since currently the joined organizations and emergency access grantees are
// not signed, manual trust prompts are required, to verify that the server
@@ -392,11 +505,16 @@ export class UserKeyRotationService {
);
return {
wasTrustDenied: false,
- trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey),
- trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey),
+ trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey as UnsignedPublicKey),
+ trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map(
+ (d) => d.publicKey as UnsignedPublicKey,
+ ),
};
}
+ /**
+ * Re-encrypts the account data owned by the user, such as ciphers, folders, and sends with the new user key.
+ */
protected async getAccountDataRequest(
originalUserKey: UserKey,
newUnencryptedUserKey: UserKey,
@@ -429,64 +547,6 @@ export class UserKeyRotationService {
return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
}
- protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise {
- // The user's account format is determined by the user key.
- // Being tied to the userkey ensures an all-or-nothing approach. A compromised
- // server cannot downgrade to a previous format (no signing keys) without
- // completely making the account unusable.
- //
- // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
- // This format is unsupported, and not secure; It is being forced migrated, and being removed
- // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
- // This format is still supported, but may be migrated in the future
- // V2: XChaCha20-Poly1305 userkey, signing key, account security version
- // This is the new, modern format.
- if (this.isV1User(oldUserKey)) {
- this.logService.info(
- "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading",
- );
- return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey;
- } else {
- // If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account.
- this.logService.info(
- "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..",
- );
- throw new Error(
- "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
- );
- }
- }
-
- protected async makeNewUserKeyV2(
- oldUserKey: UserKey,
- ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
- // The user's account format is determined by the user key.
- // Being tied to the userkey ensures an all-or-nothing approach. A compromised
- // server cannot downgrade to a previous format (no signing keys) without
- // completely making the account unusable.
- //
- // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
- // This format is unsupported, and not secure; It is being forced migrated, and being removed
- // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
- // This format is still supported, but may be migrated in the future
- // V2: XChaCha20-Poly1305 userkey, signing key, account security version
- // This is the new, modern format.
- const newUserKey: UserKey = new SymmetricCryptoKey(
- PureCrypto.make_user_key_xchacha20_poly1305(),
- ) as UserKey;
- const isUpgrading = this.isV1User(oldUserKey);
- if (isUpgrading) {
- this.logService.info(
- "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305",
- );
- } else {
- this.logService.info(
- "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed",
- );
- }
- return { isUpgrading, newUserKey };
- }
-
/**
* A V1 user has no signing key, and uses AES256-CBC-HMAC.
* A V2 user has a signing key, and uses XChaCha20-Poly1305.
@@ -516,4 +576,111 @@ export class UserKeyRotationService {
HashPurpose.ServerAuthorization,
);
}
+
+ /**
+ * Gets the cryptographic state for a user. This can be a V1 user or a V2 user.
+ */
+ protected async getCryptographicStateForUser(user: Account): Promise<{
+ masterKeyKdfConfig: KdfConfig;
+ masterKeySalt: string;
+ cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters;
+ }> {
+ // Master password unlock
+ const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow(
+ this.kdfConfigService.getKdfConfig$(user.id),
+ "KDF config",
+ ))!;
+ // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased.
+ const masterKeySalt = user.email.trim().toLowerCase();
+
+ // V1 and V2 users both have a user key and a private key
+ const currentUserKey: UserKey = (await this.firstValueFromOrThrow(
+ this.keyService.userKey$(user.id),
+ "User key",
+ ))!;
+ const currentUserKeyWrappedPrivateKey: WrappedPrivateKey = new EncString(
+ (await this.firstValueFromOrThrow(
+ this.keyService.userEncryptedPrivateKey$(user.id),
+ "Private key",
+ ))!,
+ ).encryptedString! as string as WrappedPrivateKey;
+ const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey(
+ await this.encryptService.unwrapDecapsulationKey(
+ new EncString(currentUserKeyWrappedPrivateKey),
+ currentUserKey,
+ ),
+ )) as UnsignedPublicKey;
+
+ if (this.isV1User(currentUserKey)) {
+ return {
+ masterKeyKdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters: {
+ version: 1,
+ userKey: currentUserKey,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
+ publicKey: publicKey,
+ },
+ },
+ };
+ } else if (currentUserKey.inner().type === EncryptionType.CoseEncrypt0) {
+ const signingKey = await this.firstValueFromOrThrow(
+ this.keyService.userSigningKey$(user.id),
+ "User signing key",
+ );
+ const securityState = await this.firstValueFromOrThrow(
+ this.securityStateService.accountSecurityState$(user.id),
+ "User security state",
+ );
+
+ return {
+ masterKeyKdfConfig,
+ masterKeySalt,
+ cryptographicStateParameters: {
+ version: 2,
+ userKey: currentUserKey,
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
+ publicKey: publicKey,
+ },
+ signingKey: signingKey!,
+ securityState: securityState!,
+ },
+ };
+ }
+
+ /// AES-CBC (no-hmac) keys are not supported as user keys
+ throw new Error(
+ `Unsupported user key type: ${currentUserKey.inner().type}. Expected AesCbc256_HmacSha256_B64 or XChaCha20_Poly1305_B64.`,
+ );
+ }
+
+ async firstValueFromOrThrow(value: Observable, name: string): Promise {
+ const result = await firstValueFrom(value);
+ if (result == null) {
+ throw new Error(`Failed to get ${name}`);
+ }
+ return result as T;
+ }
}
+
+export type V1CryptographicStateParameters = {
+ version: 1;
+ userKey: UserKey;
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: WrappedPrivateKey;
+ publicKey: UnsignedPublicKey;
+ };
+};
+
+export type V2CryptographicStateParameters = {
+ version: 2;
+ userKey: UserKey;
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: WrappedPrivateKey;
+ publicKey: UnsignedPublicKey;
+ };
+ signingKey: WrappedSigningKey;
+ securityState: SignedSecurityState;
+};
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 15f52d0e65c..717a6c501c9 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -170,6 +170,8 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
+import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
+import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
@@ -177,6 +179,8 @@ import {
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
+import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
SendPasswordService,
DefaultSendPasswordService,
@@ -702,6 +706,11 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
],
}),
+ safeProvider({
+ provide: SecurityStateService,
+ useClass: DefaultSecurityStateService,
+ deps: [StateProvider],
+ }),
safeProvider({
provide: RestrictedItemTypesService,
useClass: RestrictedItemTypesService,
@@ -797,6 +806,11 @@ const safeProviders: SafeProvider[] = [
useClass: SendApiService,
deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService],
}),
+ safeProvider({
+ provide: KeyApiService,
+ useClass: DefaultKeyApiService,
+ deps: [ApiServiceAbstraction],
+ }),
safeProvider({
provide: SyncService,
useClass: DefaultSyncService,
@@ -825,6 +839,7 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction,
AuthServiceAbstraction,
StateProvider,
+ SecurityStateService,
],
}),
safeProvider({
@@ -1523,6 +1538,7 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
KdfConfigService,
KeyService,
+ SecurityStateService,
ApiServiceAbstraction,
StateProvider,
ConfigService,
diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts
index 175da716803..11c186bc393 100644
--- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts
+++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts
@@ -8,6 +8,7 @@ import {
} from "../../../platform/models/domain/decrypt-parameters";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../../types/csprng";
+import { UnsignedPublicKey } from "../../types";
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
export class WebCryptoFunctionService implements CryptoFunctionService {
@@ -309,7 +310,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
"encrypt",
]);
const buffer = await this.subtle.exportKey("spki", impPublicKey);
- return new Uint8Array(buffer);
+ return new Uint8Array(buffer) as UnsignedPublicKey;
}
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise {
diff --git a/libs/common/src/key-management/enums/signing-key-type.enum.ts b/libs/common/src/key-management/enums/signing-key-type.enum.ts
new file mode 100644
index 00000000000..b1370ef0034
--- /dev/null
+++ b/libs/common/src/key-management/enums/signing-key-type.enum.ts
@@ -0,0 +1,13 @@
+export const SigningKeyTypes = {
+ Ed25519: "ed25519",
+} as const;
+
+export type SigningKeyType = (typeof SigningKeyTypes)[keyof typeof SigningKeyTypes];
+export function parseSigningKeyTypeFromString(value: string): SigningKeyType {
+ switch (value) {
+ case SigningKeyTypes.Ed25519:
+ return SigningKeyTypes.Ed25519;
+ default:
+ throw new Error(`Unknown signing key type: ${value}`);
+ }
+}
diff --git a/libs/common/src/key-management/keys/response/private-keys.response.ts b/libs/common/src/key-management/keys/response/private-keys.response.ts
new file mode 100644
index 00000000000..2bd723fb455
--- /dev/null
+++ b/libs/common/src/key-management/keys/response/private-keys.response.ts
@@ -0,0 +1,55 @@
+import { SecurityStateResponse } from "../../security-state/response/security-state.response";
+
+import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
+import { SignatureKeyPairResponse } from "./signature-key-pair.response";
+
+/**
+ * The privately accessible view of an entity (account / org)'s keys.
+ * This includes the full key-pairs for public-key encryption and signing, as well as the security state if available.
+ */
+export class PrivateKeysResponseModel {
+ readonly publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairResponse;
+ readonly signatureKeyPair: SignatureKeyPairResponse | null = null;
+ readonly securityState: SecurityStateResponse | null = null;
+
+ constructor(response: unknown) {
+ if (typeof response !== "object" || response == null) {
+ throw new TypeError("Response must be an object");
+ }
+
+ if (
+ !("publicKeyEncryptionKeyPair" in response) ||
+ typeof response.publicKeyEncryptionKeyPair !== "object"
+ ) {
+ throw new TypeError("Response must contain a valid publicKeyEncryptionKeyPair");
+ }
+ this.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairResponse(
+ response.publicKeyEncryptionKeyPair,
+ );
+
+ if (
+ "signatureKeyPair" in response &&
+ typeof response.signatureKeyPair === "object" &&
+ response.signatureKeyPair != null
+ ) {
+ this.signatureKeyPair = new SignatureKeyPairResponse(response.signatureKeyPair);
+ }
+
+ if (
+ "securityState" in response &&
+ typeof response.securityState === "object" &&
+ response.securityState != null
+ ) {
+ this.securityState = new SecurityStateResponse(response.securityState);
+ }
+
+ if (
+ (this.signatureKeyPair !== null && this.securityState === null) ||
+ (this.signatureKeyPair === null && this.securityState !== null)
+ ) {
+ throw new TypeError(
+ "Both signatureKeyPair and securityState must be present or absent together",
+ );
+ }
+ }
+}
diff --git a/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts
new file mode 100644
index 00000000000..4b1e3f90e29
--- /dev/null
+++ b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts
@@ -0,0 +1,32 @@
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+
+import { SignedPublicKey, UnsignedPublicKey, WrappedPrivateKey } from "../../types";
+
+export class PublicKeyEncryptionKeyPairResponse {
+ readonly wrappedPrivateKey: WrappedPrivateKey;
+ readonly publicKey: UnsignedPublicKey;
+
+ readonly signedPublicKey: SignedPublicKey | null = null;
+
+ constructor(response: unknown) {
+ if (typeof response !== "object" || response == null) {
+ throw new TypeError("Response must be an object");
+ }
+
+ if (!("publicKey" in response) || typeof response.publicKey !== "string") {
+ throw new TypeError("Response must contain a valid publicKey");
+ }
+ this.publicKey = Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey;
+
+ if (!("wrappedPrivateKey" in response) || typeof response.wrappedPrivateKey !== "string") {
+ throw new TypeError("Response must contain a valid wrappedPrivateKey");
+ }
+ this.wrappedPrivateKey = response.wrappedPrivateKey as WrappedPrivateKey;
+
+ if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") {
+ this.signedPublicKey = response.signedPublicKey as SignedPublicKey;
+ } else {
+ this.signedPublicKey = null;
+ }
+ }
+}
diff --git a/libs/common/src/key-management/keys/response/public-keys.response.ts b/libs/common/src/key-management/keys/response/public-keys.response.ts
new file mode 100644
index 00000000000..cf4b3efc349
--- /dev/null
+++ b/libs/common/src/key-management/keys/response/public-keys.response.ts
@@ -0,0 +1,44 @@
+import { SignedPublicKey } from "@bitwarden/sdk-internal";
+
+import { UnsignedPublicKey, VerifyingKey } from "../../types";
+
+/**
+ * The publicly accessible view of an entity (account / org)'s keys. That includes the encryption public key, and the verifying key if available.
+ */
+export class PublicKeysResponseModel {
+ readonly publicKey: UnsignedPublicKey;
+ readonly verifyingKey: VerifyingKey | null;
+ readonly signedPublicKey?: SignedPublicKey | null;
+
+ constructor(response: unknown) {
+ if (typeof response !== "object" || response == null) {
+ throw new TypeError("Response must be an object");
+ }
+
+ if (!("publicKey" in response) || !(response.publicKey instanceof Uint8Array)) {
+ throw new TypeError("Response must contain a valid publicKey");
+ }
+ this.publicKey = response.publicKey as UnsignedPublicKey;
+
+ if ("verifyingKey" in response && typeof response.verifyingKey === "string") {
+ this.verifyingKey = response.verifyingKey as VerifyingKey;
+ } else {
+ this.verifyingKey = null;
+ }
+
+ if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") {
+ this.signedPublicKey = response.signedPublicKey as SignedPublicKey;
+ } else {
+ this.signedPublicKey = null;
+ }
+
+ if (
+ (this.signedPublicKey !== null && this.verifyingKey === null) ||
+ (this.signedPublicKey === null && this.verifyingKey !== null)
+ ) {
+ throw new TypeError(
+ "Both signedPublicKey and verifyingKey must be present or absent together",
+ );
+ }
+ }
+}
diff --git a/libs/common/src/key-management/keys/response/signature-key-pair.response.ts b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts
new file mode 100644
index 00000000000..2499839b64e
--- /dev/null
+++ b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts
@@ -0,0 +1,22 @@
+import { VerifyingKey, WrappedSigningKey } from "../../types";
+
+export class SignatureKeyPairResponse {
+ readonly wrappedSigningKey: WrappedSigningKey;
+ readonly verifyingKey: VerifyingKey;
+
+ constructor(response: unknown) {
+ if (typeof response !== "object" || response == null) {
+ throw new TypeError("Response must be an object");
+ }
+
+ if (!("wrappedSigningKey" in response) || typeof response.wrappedSigningKey !== "string") {
+ throw new TypeError("Response must contain a valid wrappedSigningKey");
+ }
+ this.wrappedSigningKey = response.wrappedSigningKey as WrappedSigningKey;
+
+ if (!("verifyingKey" in response) || typeof response.verifyingKey !== "string") {
+ throw new TypeError("Response must contain a valid verifyingKey");
+ }
+ this.verifyingKey = response.verifyingKey as VerifyingKey;
+ }
+}
diff --git a/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts
new file mode 100644
index 00000000000..93556dbb57a
--- /dev/null
+++ b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts
@@ -0,0 +1,5 @@
+import { PublicKeysResponseModel } from "../../response/public-keys.response";
+
+export abstract class KeyApiService {
+ abstract getUserPublicKeys(id: string): Promise;
+}
diff --git a/libs/common/src/key-management/keys/services/default-key-api-service.service.ts b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts
new file mode 100644
index 00000000000..fd8321055be
--- /dev/null
+++ b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts
@@ -0,0 +1,15 @@
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { ApiService } from "../../../abstractions/api.service";
+import { PublicKeysResponseModel } from "../response/public-keys.response";
+
+import { KeyApiService } from "./abstractions/key-api-service.abstraction";
+
+export class DefaultKeyApiService implements KeyApiService {
+ constructor(private apiService: ApiService) {}
+
+ async getUserPublicKeys(id: UserId): Promise {
+ const response = await this.apiService.send("GET", "/users/" + id + "/keys", null, true, true);
+ return new PublicKeysResponseModel(response);
+ }
+}
diff --git a/libs/common/src/key-management/security-state/abstractions/security-state.service.ts b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts
new file mode 100644
index 00000000000..466095c2f45
--- /dev/null
+++ b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts
@@ -0,0 +1,21 @@
+import { Observable } from "rxjs";
+
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { SignedSecurityState } from "../../types";
+
+export abstract class SecurityStateService {
+ /**
+ * Retrieves the security state for the provided user.
+ * Note: This state is not yet validated. To get a validated state, the SDK crypto client
+ * must be used. This security state is validated on initialization of the SDK.
+ */
+ abstract accountSecurityState$(userId: UserId): Observable;
+ /**
+ * Sets the security state for the provided user.
+ */
+ abstract setAccountSecurityState(
+ accountSecurityState: SignedSecurityState,
+ userId: UserId,
+ ): Promise;
+}
diff --git a/libs/common/src/key-management/security-state/request/security-state.request.ts b/libs/common/src/key-management/security-state/request/security-state.request.ts
new file mode 100644
index 00000000000..7c825bedf84
--- /dev/null
+++ b/libs/common/src/key-management/security-state/request/security-state.request.ts
@@ -0,0 +1,8 @@
+import { SignedSecurityState } from "../../types";
+
+export class SecurityStateRequest {
+ constructor(
+ readonly securityState: SignedSecurityState,
+ readonly securityVersion: number,
+ ) {}
+}
diff --git a/libs/common/src/key-management/security-state/response/security-state.response.ts b/libs/common/src/key-management/security-state/response/security-state.response.ts
new file mode 100644
index 00000000000..0590da31913
--- /dev/null
+++ b/libs/common/src/key-management/security-state/response/security-state.response.ts
@@ -0,0 +1,16 @@
+import { SignedSecurityState } from "../../types";
+
+export class SecurityStateResponse {
+ readonly securityState: SignedSecurityState | null = null;
+
+ constructor(response: unknown) {
+ if (typeof response !== "object" || response == null) {
+ throw new TypeError("Response must be an object");
+ }
+
+ if (!("securityState" in response) || !(typeof response.securityState === "string")) {
+ throw new TypeError("Response must contain a valid securityState");
+ }
+ this.securityState = response.securityState as SignedSecurityState;
+ }
+}
diff --git a/libs/common/src/key-management/security-state/services/security-state.service.ts b/libs/common/src/key-management/security-state/services/security-state.service.ts
new file mode 100644
index 00000000000..789d5171072
--- /dev/null
+++ b/libs/common/src/key-management/security-state/services/security-state.service.ts
@@ -0,0 +1,26 @@
+import { Observable } from "rxjs";
+
+import { StateProvider } from "@bitwarden/common/platform/state";
+import { UserId } from "@bitwarden/common/types/guid";
+
+import { SignedSecurityState } from "../../types";
+import { SecurityStateService } from "../abstractions/security-state.service";
+import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state";
+
+export class DefaultSecurityStateService implements SecurityStateService {
+ constructor(protected stateProvider: StateProvider) {}
+
+ // Emits the provided user's security state, or null if there is no security state present for the user.
+ accountSecurityState$(userId: UserId): Observable {
+ return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId);
+ }
+
+ // Sets the security state for the provided user.
+ // This is not yet validated, and is only validated upon SDK initialization.
+ async setAccountSecurityState(
+ accountSecurityState: SignedSecurityState,
+ userId: UserId,
+ ): Promise {
+ await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId);
+ }
+}
diff --git a/libs/common/src/key-management/security-state/state/security-state.state.ts b/libs/common/src/key-management/security-state/state/security-state.state.ts
new file mode 100644
index 00000000000..e471ef17d76
--- /dev/null
+++ b/libs/common/src/key-management/security-state/state/security-state.state.ts
@@ -0,0 +1,12 @@
+import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
+
+import { SignedSecurityState } from "../../types";
+
+export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition(
+ CRYPTO_DISK,
+ "accountSecurityState",
+ {
+ deserializer: (obj) => obj,
+ clearOn: ["logout"],
+ },
+);
diff --git a/libs/common/src/key-management/types.ts b/libs/common/src/key-management/types.ts
new file mode 100644
index 00000000000..df64a3ed342
--- /dev/null
+++ b/libs/common/src/key-management/types.ts
@@ -0,0 +1,30 @@
+import { Opaque } from "type-fest";
+
+import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal";
+
+/**
+ * A private key, encrypted with a symmetric key.
+ */
+export type WrappedPrivateKey = Opaque;
+
+/**
+ * A public key, signed with the accounts signature key.
+ */
+export type SignedPublicKey = Opaque;
+/**
+ * A public key in base64 encoded SPKI-DER
+ */
+export type UnsignedPublicKey = Opaque;
+
+/**
+ * A signature key encrypted with a symmetric key.
+ */
+export type WrappedSigningKey = Opaque;
+/**
+ * A signature public key (verifying key) in base64 encoded CoseKey format
+ */
+export type VerifyingKey = Opaque;
+/**
+ * A signed security state, encoded in base64.
+ */
+export type SignedSecurityState = Opaque;
diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts
index a6982d7ed43..11aca73d5c9 100644
--- a/libs/common/src/models/response/profile.response.ts
+++ b/libs/common/src/models/response/profile.response.ts
@@ -1,3 +1,5 @@
+import { PrivateKeysResponseModel } from "@bitwarden/common/key-management/keys/response/private-keys.response";
+
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response";
import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response";
@@ -18,7 +20,10 @@ export class ProfileResponse extends BaseResponse {
key?: EncString;
avatarColor: string;
creationDate: string;
+ // Cleanup: Can be removed after moving to accountKeys
privateKey: string;
+ // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768
+ accountKeys: PrivateKeysResponseModel | null = null;
securityStamp: string;
forcePasswordReset: boolean;
usesKeyConnector: boolean;
@@ -37,10 +42,16 @@ export class ProfileResponse extends BaseResponse {
this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization");
this.culture = this.getResponseProperty("Culture");
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
+
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);
}
+ // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768
+ if (this.getResponseProperty("AccountKeys") != null) {
+ this.accountKeys = new PrivateKeysResponseModel(this.getResponseProperty("AccountKeys"));
+ }
+
this.avatarColor = this.getResponseProperty("AvatarColor");
this.creationDate = this.getResponseProperty("CreationDate");
this.privateKey = this.getResponseProperty("PrivateKey");
diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts
index 1b88554e53b..2416c211d6b 100644
--- a/libs/common/src/platform/services/key-state/user-key.state.ts
+++ b/libs/common/src/platform/services/key-state/user-key.state.ts
@@ -1,4 +1,5 @@
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
+import { WrappedSigningKey } from "../../../key-management/types";
import { UserKey } from "../../../types/key";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state";
@@ -25,3 +26,12 @@ export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey",
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
clearOn: ["logout", "lock"],
});
+
+export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition(
+ CRYPTO_DISK,
+ "userSigningKey",
+ {
+ deserializer: (obj) => obj,
+ clearOn: ["logout"],
+ },
+);
diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts
index 7165e845885..4aee0d48e5a 100644
--- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts
+++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts
@@ -1,7 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
-import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -18,6 +18,7 @@ import { AccountInfo } from "../../../auth/abstractions/account.service";
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
+import { ConfigService } from "../../abstractions/config/config.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
@@ -43,6 +44,7 @@ describe("DefaultSdkService", () => {
let platformUtilsService!: MockProxy;
let kdfConfigService!: MockProxy;
let keyService!: MockProxy;
+ let securityStateService!: MockProxy;
let configService!: MockProxy;
let service!: DefaultSdkService;
let accountService!: FakeAccountService;
@@ -57,6 +59,7 @@ describe("DefaultSdkService", () => {
platformUtilsService = mock();
kdfConfigService = mock();
keyService = mock();
+ securityStateService = mock();
apiService = mock();
const mockUserId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(mockUserId);
@@ -75,6 +78,7 @@ describe("DefaultSdkService", () => {
accountService,
kdfConfigService,
keyService,
+ securityStateService,
apiService,
fakeStateProvider,
configService,
@@ -100,6 +104,8 @@ describe("DefaultSdkService", () => {
.calledWith(userId)
.mockReturnValue(of("private-key" as EncryptedString));
keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({}));
+ keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null));
+ securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null));
});
describe("given no client override has been set for the user", () => {
diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts
index ec57783e02f..6f9c9df761c 100644
--- a/libs/common/src/platform/services/sdk/default-sdk.service.ts
+++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts
@@ -31,6 +31,8 @@ import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { DeviceType } from "../../../enums/device-type.enum";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
+import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
+import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
import { OrganizationId, UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
@@ -98,6 +100,7 @@ export class DefaultSdkService implements SdkService {
private accountService: AccountService,
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
+ private securityStateService: SecurityStateService,
private apiService: ApiService,
private stateProvider: StateProvider,
private configService: ConfigService,
@@ -160,10 +163,14 @@ export class DefaultSdkService implements SdkService {
const privateKey$ = this.keyService
.userEncryptedPrivateKey$(userId)
.pipe(distinctUntilChanged());
+ const signingKey$ = this.keyService.userSigningKey$(userId).pipe(distinctUntilChanged());
const userKey$ = this.keyService.userKey$(userId).pipe(distinctUntilChanged());
const orgKeys$ = this.keyService.encryptedOrgKeys$(userId).pipe(
distinctUntilChanged(compareValues), // The upstream observable emits different objects with the same values
);
+ const securityState$ = this.securityStateService
+ .accountSecurityState$(userId)
+ .pipe(distinctUntilChanged(compareValues));
const client$ = combineLatest([
this.environmentService.getEnvironment$(userId),
@@ -171,51 +178,57 @@ export class DefaultSdkService implements SdkService {
kdfParams$,
privateKey$,
userKey$,
+ signingKey$,
orgKeys$,
+ securityState$,
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
]).pipe(
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
- switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => {
- // Create our own observable to be able to implement clean-up logic
- return new Observable>((subscriber) => {
- const createAndInitializeClient = async () => {
- if (env == null || kdfParams == null || privateKey == null || userKey == null) {
- return undefined;
- }
+ switchMap(
+ ([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
+ // Create our own observable to be able to implement clean-up logic
+ return new Observable>((subscriber) => {
+ const createAndInitializeClient = async () => {
+ if (env == null || kdfParams == null || privateKey == null || userKey == null) {
+ return undefined;
+ }
- const settings = this.toSettings(env);
- const client = await this.sdkClientFactory.createSdkClient(
- new JsTokenProvider(this.apiService, userId),
- settings,
- );
+ const settings = this.toSettings(env);
+ const client = await this.sdkClientFactory.createSdkClient(
+ new JsTokenProvider(this.apiService, userId),
+ settings,
+ );
- await this.initializeClient(
- userId,
- client,
- account,
- kdfParams,
- privateKey,
- userKey,
- orgKeys,
- );
+ await this.initializeClient(
+ userId,
+ client,
+ account,
+ kdfParams,
+ privateKey,
+ userKey,
+ signingKey,
+ securityState,
+ orgKeys,
+ );
- return client;
- };
+ return client;
+ };
- let client: Rc | undefined;
- createAndInitializeClient()
- .then((c) => {
- client = c === undefined ? undefined : new Rc(c);
+ let client: Rc | undefined;
+ createAndInitializeClient()
+ .then((c) => {
+ client = c === undefined ? undefined : new Rc(c);
- subscriber.next(client);
- })
- .catch((e) => {
- subscriber.error(e);
- });
+ subscriber.next(client);
+ })
+ .catch((e) => {
+ subscriber.error(e);
+ });
- return () => client?.markForDisposal();
- });
- }),
+ return () => client?.markForDisposal();
+ });
+ },
+ ),
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -231,6 +244,8 @@ export class DefaultSdkService implements SdkService {
kdfParams: KdfConfig,
privateKey: EncryptedString,
userKey: UserKey,
+ signingKey: WrappedSigningKey | null,
+ securityState: SignedSecurityState | null,
orgKeys: Record,
) {
await client.crypto().initialize_user_crypto({
@@ -248,8 +263,8 @@ export class DefaultSdkService implements SdkService {
},
},
privateKey,
- signingKey: undefined,
- securityState: undefined,
+ signingKey: signingKey || undefined,
+ securityState: securityState || undefined,
});
// We initialize the org crypto even if the org_keys are
diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts
index 193a5a2d2dd..f60b42ce450 100644
--- a/libs/common/src/platform/sync/default-sync.service.spec.ts
+++ b/libs/common/src/platform/sync/default-sync.service.spec.ts
@@ -11,6 +11,8 @@ import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
+import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -72,6 +74,7 @@ describe("DefaultSyncService", () => {
let tokenService: MockProxy;
let authService: MockProxy;
let stateProvider: MockProxy;
+ let securityStateService: MockProxy;
let sut: DefaultSyncService;
@@ -101,6 +104,7 @@ describe("DefaultSyncService", () => {
tokenService = mock();
authService = mock();
stateProvider = mock();
+ securityStateService = mock();
sut = new DefaultSyncService(
masterPasswordAbstraction,
@@ -127,6 +131,7 @@ describe("DefaultSyncService", () => {
tokenService,
authService,
stateProvider,
+ securityStateService,
);
});
@@ -155,6 +160,142 @@ describe("DefaultSyncService", () => {
stateProvider.getUser.mockReturnValue(mock());
});
+ it("sets the correct keys for a V1 user with old response model", async () => {
+ const v1Profile = {
+ id: user1,
+ key: "encryptedUserKey",
+ privateKey: "privateKey",
+ providers: [] as any[],
+ organizations: [] as any[],
+ providerOrganizations: [] as any[],
+ avatarColor: "#fff",
+ securityStamp: "stamp",
+ emailVerified: true,
+ verifyDevices: false,
+ premiumPersonally: false,
+ premiumFromOrganization: false,
+ usesKeyConnector: false,
+ };
+ apiService.getSync.mockResolvedValue(
+ new SyncResponse({
+ profile: v1Profile,
+ folders: [],
+ collections: [],
+ ciphers: [],
+ sends: [],
+ domains: [],
+ policies: [],
+ }),
+ );
+ await sut.fullSync(true);
+ expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
+ new EncString("encryptedUserKey"),
+ user1,
+ );
+ expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1);
+ expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
+ expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
+ });
+
+ it("sets the correct keys for a V1 user", async () => {
+ const v1Profile = {
+ id: user1,
+ key: "encryptedUserKey",
+ privateKey: "privateKey",
+ providers: [] as any[],
+ organizations: [] as any[],
+ providerOrganizations: [] as any[],
+ avatarColor: "#fff",
+ securityStamp: "stamp",
+ emailVerified: true,
+ verifyDevices: false,
+ premiumPersonally: false,
+ premiumFromOrganization: false,
+ usesKeyConnector: false,
+ accountKeys: {
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: "wrappedPrivateKey",
+ publicKey: "publicKey",
+ },
+ },
+ };
+ apiService.getSync.mockResolvedValue(
+ new SyncResponse({
+ profile: v1Profile,
+ folders: [],
+ collections: [],
+ ciphers: [],
+ sends: [],
+ domains: [],
+ policies: [],
+ }),
+ );
+ await sut.fullSync(true);
+ expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
+ new EncString("encryptedUserKey"),
+ user1,
+ );
+ expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1);
+ expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
+ expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
+ });
+
+ it("sets the correct keys for a V2 user", async () => {
+ const v2Profile = {
+ id: user1,
+ key: "encryptedUserKey",
+ providers: [] as unknown[],
+ organizations: [] as unknown[],
+ providerOrganizations: [] as unknown[],
+ avatarColor: "#fff",
+ securityStamp: "stamp",
+ emailVerified: true,
+ verifyDevices: false,
+ premiumPersonally: false,
+ premiumFromOrganization: false,
+ usesKeyConnector: false,
+ privateKey: "wrappedPrivateKey",
+ accountKeys: {
+ publicKeyEncryptionKeyPair: {
+ wrappedPrivateKey: "wrappedPrivateKey",
+ publicKey: "publicKey",
+ signedPublicKey: "signedPublicKey",
+ },
+ signatureKeyPair: {
+ wrappedSigningKey: "wrappedSigningKey",
+ verifyingKey: "verifyingKey",
+ },
+ securityState: {
+ securityState: "securityState",
+ },
+ },
+ };
+ apiService.getSync.mockResolvedValue(
+ new SyncResponse({
+ profile: v2Profile,
+ folders: [],
+ collections: [],
+ ciphers: [],
+ sends: [],
+ domains: [],
+ policies: [],
+ }),
+ );
+ await sut.fullSync(true);
+ expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
+ new EncString("encryptedUserKey"),
+ user1,
+ );
+ expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1);
+ expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1);
+ expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
+ "securityState",
+ user1,
+ );
+ expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1);
+ expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1);
+ });
+
it("does a token refresh when option missing from options", async () => {
await sut.fullSync(true, { allowThrowOnError: false });
diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts
index a02d602dbf0..d5fa2d0ae68 100644
--- a/libs/common/src/platform/sync/default-sync.service.ts
+++ b/libs/common/src/platform/sync/default-sync.service.ts
@@ -10,6 +10,7 @@ import {
CollectionService,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
+import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
@@ -98,6 +99,7 @@ export class DefaultSyncService extends CoreSyncService {
tokenService: TokenService,
authService: AuthService,
stateProvider: StateProvider,
+ private securityStateService: SecurityStateService,
) {
super(
tokenService,
@@ -233,13 +235,34 @@ export class DefaultSyncService extends CoreSyncService {
if (response?.key) {
await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id);
}
- await this.keyService.setPrivateKey(response.privateKey, response.id);
+
+ // Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768
+ if (response.accountKeys != null) {
+ await this.keyService.setPrivateKey(
+ response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
+ response.id,
+ );
+ if (response.accountKeys.signatureKeyPair !== null) {
+ // User is V2 user
+ await this.keyService.setUserSigningKey(
+ response.accountKeys.signatureKeyPair.wrappedSigningKey,
+ response.id,
+ );
+ await this.securityStateService.setAccountSecurityState(
+ response.accountKeys.securityState.securityState,
+ response.id,
+ );
+ }
+ } else {
+ await this.keyService.setPrivateKey(response.privateKey, response.id);
+ }
await this.keyService.setProviderKeys(response.providers, response.id);
await this.keyService.setOrgKeys(
response.organizations,
response.providerOrganizations,
response.id,
);
+
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts
index 8984452e701..ca56deb2fb1 100644
--- a/libs/common/src/types/key.ts
+++ b/libs/common/src/types/key.ts
@@ -1,5 +1,6 @@
import { Opaque } from "type-fest";
+import { UnsignedPublicKey } from "../key-management/types";
import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key";
// symmetric keys
@@ -15,4 +16,4 @@ export type CipherKey = Opaque;
// asymmetric keys
export type UserPrivateKey = Opaque;
-export type UserPublicKey = Opaque;
+export type UserPublicKey = Opaque;
diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts
index c6c751bf25c..e4bb83cb2fd 100644
--- a/libs/key-management/src/abstractions/key.service.ts
+++ b/libs/key-management/src/abstractions/key.service.ts
@@ -7,6 +7,7 @@ import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
+import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -236,8 +237,10 @@ export abstract class KeyService {
*/
abstract getOrgKey(orgId: string): Promise;
/**
- * Uses the org key to derive a new symmetric key for encrypting data
- * @param key The organization's symmetric key
+ * Makes a fresh attachment content encryption key and returns it along with a wrapped (encrypted) version of it.
+ * @deprecated Do not use this for new code / new cryptographic designs.
+ * @param key The organization's symmetric key or the user's user key to wrap the attachment key with
+ * @returns The new attachment content encryption key and the wrapped version of it
*/
abstract makeDataEncKey(
key: T,
@@ -272,6 +275,14 @@ export abstract class KeyService {
* @param encPrivateKey An encrypted private key
*/
abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise;
+ /**
+ * Sets the user's encrypted signing key in storage
+ * In contrast to the private key, the decrypted signing key
+ * is not stored in memory outside of the SDK.
+ * @param encryptedSigningKey An encrypted signing key
+ * @param userId The user id of the user to set the signing key for
+ */
+ abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise;
/**
* Gets an observable stream of the given users decrypted private key, will emit null if the user
@@ -416,7 +427,13 @@ export abstract class KeyService {
*
* @throws If an invalid user id is passed in.
*/
- abstract userPublicKey$(userId: UserId): Observable;
+ abstract userPublicKey$(userId: UserId): Observable;
+
+ /**
+ * Gets a users signing keys from local state.
+ * The observable will emit null, exactly if the local state returns null.
+ */
+ abstract userSigningKey$(userId: UserId): Observable;
/**
* Validates that a userkey is correct for a given user
diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts
index 52fecf26c71..46d1125711b 100644
--- a/libs/key-management/src/key.service.spec.ts
+++ b/libs/key-management/src/key.service.spec.ts
@@ -11,6 +11,7 @@ import {
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
+import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout";
import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -25,6 +26,7 @@ import {
USER_ENCRYPTED_PRIVATE_KEY,
USER_EVER_HAD_USER_KEY,
USER_KEY,
+ USER_KEY_ENCRYPTED_SIGNING_KEY,
} from "@bitwarden/common/platform/services/key-state/user-key.state";
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import {
@@ -432,6 +434,7 @@ describe("keyService", () => {
USER_ENCRYPTED_ORGANIZATION_KEYS,
USER_ENCRYPTED_PROVIDER_KEYS,
USER_ENCRYPTED_PRIVATE_KEY,
+ USER_KEY_ENCRYPTED_SIGNING_KEY,
USER_KEY,
])("key removal", (key: UserKeyDefinition) => {
it(`clears ${key.key} for the specified user when specified`, async () => {
@@ -540,6 +543,51 @@ describe("keyService", () => {
});
});
+ describe("userSigningKey$", () => {
+ it("returns the signing key when the user has a signing key set", async () => {
+ const fakeSigningKey = "" as WrappedSigningKey;
+ const fakeSigningKeyState = stateProvider.singleUser.getFake(
+ mockUserId,
+ USER_KEY_ENCRYPTED_SIGNING_KEY,
+ );
+ fakeSigningKeyState.nextState(fakeSigningKey);
+
+ const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId));
+
+ expect(signingKey).toEqual(fakeSigningKey);
+ });
+
+ it("returns null when the user does not have a signing key set", async () => {
+ const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId));
+
+ expect(signingKey).toBeFalsy();
+ });
+ });
+
+ describe("setUserSigningKey", () => {
+ it("throws if the signing key is null", async () => {
+ await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow(
+ "No user signing key provided.",
+ );
+ });
+ it("throws if the userId is null", async () => {
+ await expect(
+ keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId),
+ ).rejects.toThrow("No userId provided.");
+ });
+ it("sets the signing key for the user", async () => {
+ const fakeSigningKey = "" as WrappedSigningKey;
+ const fakeSigningKeyState = stateProvider.singleUser.getFake(
+ mockUserId,
+ USER_KEY_ENCRYPTED_SIGNING_KEY,
+ );
+ fakeSigningKeyState.nextState(null);
+ await keyService.setUserSigningKey(fakeSigningKey, mockUserId);
+ expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1);
+ expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey);
+ });
+ });
+
describe("cipherDecryptionKeys$", () => {
function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) {
const output = new Uint8Array(64);
@@ -1132,12 +1180,12 @@ describe("keyService", () => {
keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key"));
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
- Utils.fromUtf8ToArray("public key"),
+ Utils.fromUtf8ToArray("public key") as UnsignedPublicKey,
);
const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId));
expect(key).toEqual({
privateKey: "private key",
- publicKey: Utils.fromUtf8ToArray("public key"),
+ publicKey: Utils.fromUtf8ToArray("public key") as UnsignedPublicKey,
});
});
});
diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts
index cdf7d594f28..a13c74e96d2 100644
--- a/libs/key-management/src/key.service.ts
+++ b/libs/key-management/src/key.service.ts
@@ -28,6 +28,7 @@ import {
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
+import { WrappedSigningKey } from "@bitwarden/common/key-management/types";
import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout";
import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -44,6 +45,7 @@ import {
USER_ENCRYPTED_PRIVATE_KEY,
USER_EVER_HAD_USER_KEY,
USER_KEY,
+ USER_KEY_ENCRYPTED_SIGNING_KEY,
} from "@bitwarden/common/platform/services/key-state/user-key.state";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -398,8 +400,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
throw new Error("No key provided");
}
- const newSymKey = await this.keyGenerationService.createKey(512);
- return this.buildProtectedSymmetricKey(key, newSymKey);
+ // Content encryption key is AES256_CBC_HMAC
+ const cek = await this.keyGenerationService.createKey(512);
+ const wrappedCek = await this.encryptService.wrapSymmetricKey(cek, key);
+ return [cek, wrappedCek];
}
private async clearOrgKeys(userId: UserId): Promise {
@@ -505,6 +509,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId);
}
+ private async clearSigningKey(userId: UserId): Promise {
+ await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId);
+ }
+
async clearPinKeys(userId: UserId): Promise {
if (userId == null) {
throw new Error("UserId is required");
@@ -537,6 +545,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.clearOrgKeys(userId);
await this.clearProviderKeys(userId);
await this.clearKeyPair(userId);
+ await this.clearSigningKey(userId);
await this.clearPinKeys(userId);
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId);
}
@@ -758,6 +767,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return phrase;
}
+ /**
+ * @deprecated
+ * This should only be used for wrapping the user key with a master key or stretched master key.
+ */
private async buildProtectedSymmetricKey(
encryptionKey: SymmetricCryptoKey,
newSymKey: SymmetricCryptoKey,
@@ -792,7 +805,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return null;
}
- return (await this.cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
+ return await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
}
userPrivateKey$(userId: UserId): Observable {
@@ -808,7 +821,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return null;
}
- const publicKey = (await this.derivePublicKey(privateKey))!;
+ const publicKey = (await this.derivePublicKey(privateKey))! as UserPublicKey;
return { privateKey, publicKey };
}),
);
@@ -905,6 +918,27 @@ export class DefaultKeyService implements KeyServiceAbstraction {
);
}
+ async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise {
+ if (userSigningKey == null) {
+ throw new Error("No user signing key provided.");
+ }
+ if (userId == null) {
+ throw new Error("No userId provided.");
+ }
+ await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId);
+ }
+
+ userSigningKey$(userId: UserId): Observable {
+ return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe(
+ map((encryptedSigningKey) => {
+ if (encryptedSigningKey == null) {
+ return null;
+ }
+ return encryptedSigningKey as WrappedSigningKey;
+ }),
+ );
+ }
+
orgKeys$(userId: UserId): Observable | null> {
return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null));
}
diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts
index f3ac71b13e7..22cc5756f30 100644
--- a/libs/node/src/services/node-crypto-function.service.ts
+++ b/libs/node/src/services/node-crypto-function.service.ts
@@ -3,6 +3,7 @@ import * as crypto from "crypto";
import * as forge from "node-forge";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
+import { UnsignedPublicKey } from "@bitwarden/common/key-management/types";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
@@ -232,7 +233,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
return Promise.resolve(this.toUint8Buffer(decipher));
}
- rsaExtractPublicKey(privateKey: Uint8Array): Promise {
+ async rsaExtractPublicKey(privateKey: Uint8Array): Promise {
const privateKeyByteString = Utils.fromBufferToByteString(privateKey);
const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString);
const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1);
@@ -240,11 +241,11 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey);
const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data;
const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString);
- return Promise.resolve(publicKeyArray);
+ return publicKeyArray as UnsignedPublicKey;
}
- async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
- return new Promise<[Uint8Array, Uint8Array]>((resolve, reject) => {
+ async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> {
+ return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => {
forge.pki.rsa.generateKeyPair(
{
bits: length,
@@ -266,7 +267,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService {
const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes();
const privateKey = Utils.fromByteStringToArray(privateKeyByteString);
- resolve([publicKey, privateKey]);
+ resolve([publicKey as UnsignedPublicKey, privateKey]);
},
);
});
From 601c1cc0d415aca92ad82d86bdb0f2639a00e58c Mon Sep 17 00:00:00 2001
From: Mick Letofsky
Date: Sat, 11 Oct 2025 09:22:11 +0200
Subject: [PATCH 06/37] PM-26727 Bring back references section (#16826)
---
CLAUDE.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9739288aac8..dd3b6445edd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,3 +96,13 @@ enum CipherType {
```
Example: `/libs/common/src/vault/enums/cipher-type.ts`
+
+## References
+
+- [Web Clients Architecture](https://contributing.bitwarden.com/architecture/clients)
+- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
+- [Contributing Guide](https://contributing.bitwarden.com/)
+- [Web Clients Setup Guide](https://contributing.bitwarden.com/getting-started/clients/)
+- [Code Style](https://contributing.bitwarden.com/contributing/code-style/)
+- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
+- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions)
From 14e7ee4818f1fc3d80b40c3576f3a7d7cad18042 Mon Sep 17 00:00:00 2001
From: Addison Beck
Date: Mon, 13 Oct 2025 06:31:17 -0400
Subject: [PATCH 07/37] build(web): integrate nx (#16706)
---
apps/web/postcss.config.js | 8 +-
apps/web/project.json | 215 ++++++++++++++++++++
apps/web/tailwind.config.js | 19 +-
apps/web/webpack.base.js | 117 +++++++----
apps/web/webpack.config.js | 37 +++-
bitwarden_license/bit-web/webpack.config.js | 39 +++-
6 files changed, 368 insertions(+), 67 deletions(-)
create mode 100644 apps/web/project.json
diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js
index 5657df3afcf..0403fe4061e 100644
--- a/apps/web/postcss.config.js
+++ b/apps/web/postcss.config.js
@@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/no-require-imports */
+const path = require("path");
+
module.exports = {
plugins: [
- require("postcss-import"),
+ require("postcss-import")({
+ path: [path.resolve(__dirname, "../../libs"), path.resolve(__dirname, "src/scss")],
+ }),
require("postcss-nested"),
- require("tailwindcss"),
+ require("tailwindcss")({ config: path.resolve(__dirname, "tailwind.config.js") }),
require("autoprefixer"),
],
};
diff --git a/apps/web/project.json b/apps/web/project.json
new file mode 100644
index 00000000000..4f51bf22740
--- /dev/null
+++ b/apps/web/project.json
@@ -0,0 +1,215 @@
+{
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "name": "web",
+ "projectType": "application",
+ "sourceRoot": "apps/web/src",
+ "tags": ["scope:web", "type:app"],
+ "targets": {
+ "build": {
+ "executor": "@nx/webpack:webpack",
+ "outputs": ["{options.outputPath}"],
+ "defaultConfiguration": "oss",
+ "options": {
+ "outputPath": "dist/apps/web",
+ "webpackConfig": "apps/web/webpack.config.js",
+ "tsConfig": "apps/web/tsconfig.json",
+ "main": "apps/web/src/main.ts",
+ "target": "web",
+ "compiler": "tsc"
+ },
+ "configurations": {
+ "oss": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/oss"
+ },
+ "oss-dev": {
+ "mode": "development",
+ "outputPath": "dist/apps/web/oss-dev",
+ "env": {
+ "NODE_ENV": "development",
+ "ENV": "development"
+ }
+ },
+ "commercial": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts"
+ },
+ "commercial-dev": {
+ "mode": "development",
+ "outputPath": "dist/apps/web/commercial-dev",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "development",
+ "ENV": "development"
+ }
+ },
+ "commercial-qa": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-qa",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "qa"
+ }
+ },
+ "commercial-cloud": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-cloud",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "cloud"
+ }
+ },
+ "commercial-euprd": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-euprd",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "euprd"
+ }
+ },
+ "commercial-euqa": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-euqa",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "euqa"
+ }
+ },
+ "commercial-usdev": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-usdev",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "usdev"
+ }
+ },
+ "commercial-ee": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-ee",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "production",
+ "ENV": "ee"
+ }
+ },
+ "oss-selfhost": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/oss-selfhost",
+ "env": {
+ "ENV": "selfhosted",
+ "NODE_ENV": "production"
+ }
+ },
+ "oss-selfhost-dev": {
+ "mode": "development",
+ "outputPath": "dist/apps/web/oss-selfhost-dev",
+ "env": {
+ "NODE_ENV": "development",
+ "ENV": "selfhosted"
+ }
+ },
+ "commercial-selfhost": {
+ "mode": "production",
+ "outputPath": "dist/apps/web/commercial-selfhost",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "ENV": "selfhosted",
+ "NODE_ENV": "production"
+ }
+ },
+ "commercial-selfhost-dev": {
+ "mode": "development",
+ "outputPath": "dist/apps/web/commercial-selfhost-dev",
+ "webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
+ "main": "bitwarden_license/bit-web/src/main.ts",
+ "env": {
+ "NODE_ENV": "development",
+ "ENV": "selfhosted"
+ }
+ }
+ }
+ },
+ "serve": {
+ "executor": "@nx/webpack:dev-server",
+ "defaultConfiguration": "oss-dev",
+ "options": {
+ "buildTarget": "web:build",
+ "host": "localhost",
+ "port": 8080
+ },
+ "configurations": {
+ "oss": {
+ "buildTarget": "web:build:oss"
+ },
+ "oss-dev": {
+ "buildTarget": "web:build:oss-dev"
+ },
+ "commercial": {
+ "buildTarget": "web:build:commercial"
+ },
+ "commercial-dev": {
+ "buildTarget": "web:build:commercial-dev"
+ },
+ "commercial-qa": {
+ "buildTarget": "web:build:commercial-qa"
+ },
+ "commercial-cloud": {
+ "buildTarget": "web:build:commercial-cloud"
+ },
+ "commercial-euprd": {
+ "buildTarget": "web:build:commercial-euprd"
+ },
+ "commercial-euqa": {
+ "buildTarget": "web:build:commercial-euqa"
+ },
+ "commercial-usdev": {
+ "buildTarget": "web:build:commercial-usdev"
+ },
+ "commercial-ee": {
+ "buildTarget": "web:build:commercial-ee"
+ },
+ "oss-selfhost": {
+ "buildTarget": "web:build:oss-selfhost"
+ },
+ "oss-selfhost-dev": {
+ "buildTarget": "web:build:oss-selfhost-dev"
+ },
+ "commercial-selfhost": {
+ "buildTarget": "web:build:commercial-selfhost"
+ },
+ "commercial-selfhost-dev": {
+ "buildTarget": "web:build:commercial-selfhost-dev"
+ }
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "apps/web/jest.config.js"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["apps/web/**/*.ts", "apps/web/**/*.html"]
+ }
+ }
+ }
+}
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index 58812f4c6b7..865d681e91d 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -1,16 +1,17 @@
/* eslint-disable no-undef, @typescript-eslint/no-var-requires */
+const path = require("path");
const config = require("../../libs/components/tailwind.config.base");
config.content = [
- "./src/**/*.{html,ts}",
- "../../libs/components/src/**/*.{html,ts}",
- "../../libs/assets/src/**/*.{html,ts}",
- "../../libs/auth/src/**/*.{html,ts}",
- "../../libs/key-management-ui/src/**/*.{html,ts}",
- "../../libs/vault/src/**/*.{html,ts}",
- "../../libs/angular/src/**/*.{html,ts}",
- "../../libs/tools/generator/components/src/**/*.{html,ts}",
- "../../bitwarden_license/bit-web/src/**/*.{html,ts}",
+ path.resolve(__dirname, "./src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/components/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/assets/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/auth/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/key-management-ui/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/vault/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/angular/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../libs/tools/generator/components/src/**/*.{html,ts}"),
+ path.resolve(__dirname, "../../bitwarden_license/bit-web/src/**/*.{html,ts}"),
];
config.corePlugins.preflight = true;
diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js
index 2bfe0e27553..7930a55f61a 100644
--- a/apps/web/webpack.base.js
+++ b/apps/web/webpack.base.js
@@ -9,17 +9,21 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const webpack = require("webpack");
-const config = require("./config.js");
-const pjson = require("./package.json");
+const config = require(path.resolve(__dirname, "config.js"));
+const pjson = require(path.resolve(__dirname, "package.json"));
-module.exports.getEnv = function getEnv() {
- const ENV = process.env.ENV == null ? "development" : process.env.ENV;
+module.exports.getEnv = function getEnv(params) {
+ const ENV = params.env || (process.env.ENV == null ? "development" : process.env.ENV);
const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV;
const LOGGING = process.env.LOGGING != "false";
return { ENV, NODE_ENV, LOGGING };
};
+const DEFAULT_PARAMS = {
+ outputPath: path.resolve(__dirname, "build"),
+};
+
/**
*
* @param {{
@@ -29,10 +33,14 @@ module.exports.getEnv = function getEnv() {
* entryModule: string;
* };
* tsConfig: string;
+ * outputPath?: string;
+ * mode?: string;
+ * env?: string;
* }} params
*/
module.exports.buildConfig = function buildConfig(params) {
- const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv();
+ params = { ...DEFAULT_PARAMS, ...params };
+ const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv(params);
const envConfig = config.load(ENV);
if (LOGGING) {
@@ -89,6 +97,9 @@ module.exports.buildConfig = function buildConfig(params) {
loader: "postcss-loader",
options: {
sourceMap: true,
+ postcssOptions: {
+ config: path.resolve(__dirname, "postcss.config.js"),
+ },
},
},
],
@@ -99,7 +110,7 @@ module.exports.buildConfig = function buildConfig(params) {
{
loader: "babel-loader",
options: {
- configFile: "../../babel.config.json",
+ configFile: path.resolve(__dirname, "../../babel.config.json"),
cacheDirectory: NODE_ENV !== "production",
},
},
@@ -113,43 +124,43 @@ module.exports.buildConfig = function buildConfig(params) {
const plugins = [
new HtmlWebpackPlugin({
- template: "./src/index.html",
+ template: path.resolve(__dirname, "src/index.html"),
filename: "index.html",
chunks: ["theme_head", "app/polyfills", "app/vendor", "app/main", "styles"],
}),
new HtmlWebpackInjector(),
new HtmlWebpackPlugin({
- template: "./src/connectors/webauthn.html",
+ template: path.resolve(__dirname, "src/connectors/webauthn.html"),
filename: "webauthn-connector.html",
chunks: ["connectors/webauthn", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/connectors/webauthn-mobile.html",
+ template: path.resolve(__dirname, "src/connectors/webauthn-mobile.html"),
filename: "webauthn-mobile-connector.html",
chunks: ["connectors/webauthn", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/connectors/webauthn-fallback.html",
+ template: path.resolve(__dirname, "src/connectors/webauthn-fallback.html"),
filename: "webauthn-fallback-connector.html",
chunks: ["connectors/webauthn-fallback", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/connectors/sso.html",
+ template: path.resolve(__dirname, "src/connectors/sso.html"),
filename: "sso-connector.html",
chunks: ["connectors/sso", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/connectors/redirect.html",
+ template: path.resolve(__dirname, "src/connectors/redirect.html"),
filename: "redirect-connector.html",
chunks: ["connectors/redirect", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/connectors/duo-redirect.html",
+ template: path.resolve(__dirname, "src/connectors/duo-redirect.html"),
filename: "duo-redirect-connector.html",
chunks: ["connectors/duo-redirect", "styles"],
}),
new HtmlWebpackPlugin({
- template: "./src/404.html",
+ template: path.resolve(__dirname, "src/404.html"),
filename: "404.html",
chunks: ["styles"],
// 404 page is a wildcard, this ensures it uses absolute paths.
@@ -157,18 +168,28 @@ module.exports.buildConfig = function buildConfig(params) {
}),
new CopyWebpackPlugin({
patterns: [
- { from: "./src/.nojekyll" },
- { from: "./src/manifest.json" },
- { from: "./src/favicon.ico" },
- { from: "./src/browserconfig.xml" },
- { from: "./src/app-id.json" },
- { from: "./src/images", to: "images" },
- { from: "./src/videos", to: "videos" },
- { from: "./src/locales", to: "locales" },
- { from: "../../node_modules/qrious/dist/qrious.min.js", to: "scripts" },
- { from: "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", to: "scripts" },
+ { from: path.resolve(__dirname, "src/.nojekyll") },
+ { from: path.resolve(__dirname, "src/manifest.json") },
+ { from: path.resolve(__dirname, "src/favicon.ico") },
+ { from: path.resolve(__dirname, "src/browserconfig.xml") },
+ { from: path.resolve(__dirname, "src/app-id.json") },
+ { from: path.resolve(__dirname, "src/images"), to: "images" },
+ { from: path.resolve(__dirname, "src/images/icons"), to: "images" },
+ { from: path.resolve(__dirname, "src/videos"), to: "videos" },
+ { from: path.resolve(__dirname, "src/locales"), to: "locales" },
{
- from: "./src/version.json",
+ from: path.resolve(__dirname, "../../node_modules/qrious/dist/qrious.min.js"),
+ to: "scripts",
+ },
+ {
+ from: path.resolve(
+ __dirname,
+ "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js",
+ ),
+ to: "scripts",
+ },
+ {
+ from: path.resolve(__dirname, "src/version.json"),
transform(content, path) {
return content.toString().replace("process.env.APPLICATION_VERSION", pjson.version);
},
@@ -203,7 +224,9 @@ module.exports.buildConfig = function buildConfig(params) {
];
// ref: https://webpack.js.org/configuration/dev-server/#devserver
- let certSuffix = fs.existsSync("dev-server.local.pem") ? ".local" : ".shared";
+ let certSuffix = fs.existsSync(path.resolve(__dirname, "dev-server.local.pem"))
+ ? ".local"
+ : ".shared";
const devServer =
NODE_ENV !== "development"
? {}
@@ -211,8 +234,8 @@ module.exports.buildConfig = function buildConfig(params) {
server: {
type: "https",
options: {
- key: fs.readFileSync("dev-server" + certSuffix + ".pem"),
- cert: fs.readFileSync("dev-server" + certSuffix + ".pem"),
+ key: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")),
+ cert: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")),
},
},
// host: '192.168.1.9',
@@ -332,6 +355,15 @@ module.exports.buildConfig = function buildConfig(params) {
hot: false,
port: envConfig.dev?.port ?? 8080,
allowedHosts: envConfig.dev?.allowedHosts ?? "auto",
+ static: {
+ directory: path.resolve(params.outputPath),
+ publicPath: "/",
+ },
+ devMiddleware: {
+ // when running `serve` locally this option writes all built files to `dist`
+ // files are still served from memory, this is just a helpful debug tool
+ writeToDisk: true,
+ },
client: {
overlay: {
errors: true,
@@ -347,15 +379,21 @@ module.exports.buildConfig = function buildConfig(params) {
devServer: devServer,
target: "web",
entry: {
- "app/polyfills": "./src/polyfills.ts",
+ "app/polyfills": path.resolve(__dirname, "src/polyfills.ts"),
"app/main": params.app.entry,
- "connectors/webauthn": "./src/connectors/webauthn.ts",
- "connectors/webauthn-fallback": "./src/connectors/webauthn-fallback.ts",
- "connectors/sso": "./src/connectors/sso.ts",
- "connectors/duo-redirect": "./src/connectors/duo-redirect.ts",
- "connectors/redirect": "./src/connectors/redirect.ts",
- styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"],
- theme_head: "./src/theme.ts",
+ "connectors/webauthn": path.resolve(__dirname, "src/connectors/webauthn.ts"),
+ "connectors/webauthn-fallback": path.resolve(
+ __dirname,
+ "src/connectors/webauthn-fallback.ts",
+ ),
+ "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"),
+ "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"),
+ "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"),
+ styles: [
+ path.resolve(__dirname, "src/scss/styles.scss"),
+ path.resolve(__dirname, "src/scss/tailwind.css"),
+ ],
+ theme_head: path.resolve(__dirname, "src/theme.ts"),
},
cache:
NODE_ENV === "production"
@@ -402,7 +440,10 @@ module.exports.buildConfig = function buildConfig(params) {
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
- modules: [path.resolve("../../node_modules")],
+ modules: [
+ path.resolve(__dirname, "../../node_modules"),
+ path.resolve(process.cwd(), "node_modules"),
+ ],
fallback: {
buffer: false,
util: require.resolve("util/"),
@@ -415,7 +456,7 @@ module.exports.buildConfig = function buildConfig(params) {
},
output: {
filename: "[name].[contenthash].js",
- path: path.resolve(__dirname, "build"),
+ path: params.outputPath,
clean: true,
},
module: {
diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js
index e9d7bd46002..962d72ac825 100644
--- a/apps/web/webpack.config.js
+++ b/apps/web/webpack.config.js
@@ -1,10 +1,29 @@
-const { buildConfig } = require("./webpack.base");
+const path = require("path");
+const { buildConfig } = require(path.resolve(__dirname, "webpack.base"));
-module.exports = buildConfig({
- configName: "OSS",
- app: {
- entry: "./src/main.ts",
- entryModule: "src/app/app.module#AppModule",
- },
- tsConfig: "tsconfig.build.json",
-});
+module.exports = (webpackConfig, context) => {
+ const isNxBuild = context && context.options;
+
+ if (isNxBuild) {
+ return buildConfig({
+ configName: "OSS",
+ app: {
+ entry: context.options.main
+ ? path.resolve(context.context.root, context.options.main)
+ : path.resolve(__dirname, "src/main.ts"),
+ entryModule: "src/app/app.module#AppModule",
+ },
+ tsConfig: "apps/web/tsconfig.build.json",
+ outputPath: path.resolve(context.context.root, context.options.outputPath),
+ });
+ } else {
+ return buildConfig({
+ configName: "OSS",
+ app: {
+ entry: path.resolve(__dirname, "src/main.ts"),
+ entryModule: "src/app/app.module#AppModule",
+ },
+ tsConfig: "tsconfig.build.json",
+ });
+ }
+};
diff --git a/bitwarden_license/bit-web/webpack.config.js b/bitwarden_license/bit-web/webpack.config.js
index 37e0a0c5e03..6ac1efdc192 100644
--- a/bitwarden_license/bit-web/webpack.config.js
+++ b/bitwarden_license/bit-web/webpack.config.js
@@ -1,10 +1,31 @@
-const { buildConfig } = require("../../apps/web/webpack.base");
+const path = require("path");
+const { buildConfig } = require(path.resolve(__dirname, "../../apps/web/webpack.base"));
-module.exports = buildConfig({
- configName: "Commercial",
- app: {
- entry: "../../bitwarden_license/bit-web/src/main.ts",
- entryModule: "../../bitwarden_license/bit-web/src/app/app.module#AppModule",
- },
- tsConfig: "../../bitwarden_license/bit-web/tsconfig.build.json",
-});
+module.exports = (webpackConfig, context) => {
+ const isNxBuild = context && context.options;
+ if (isNxBuild) {
+ return buildConfig({
+ configName: "Commercial",
+ app: {
+ entry: context.options.main
+ ? path.resolve(context.context.root, context.options.main)
+ : path.resolve(__dirname, "src/main.ts"),
+ entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule",
+ },
+ tsConfig: "bitwarden_license/bit-web/tsconfig.build.json",
+ outputPath:
+ context.context && context.context.root
+ ? path.resolve(context.context.root, context.options.outputPath)
+ : context.options.outputPath,
+ });
+ } else {
+ return buildConfig({
+ configName: "Commercial",
+ app: {
+ entry: path.resolve(__dirname, "src/main.ts"),
+ entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule",
+ },
+ tsConfig: path.resolve(__dirname, "tsconfig.build.json"),
+ });
+ }
+};
From a7242a1186b6e7e98eb016305d4e7ad461abd6f2 Mon Sep 17 00:00:00 2001
From: Bernd Schoolmann
Date: Mon, 13 Oct 2025 15:06:41 +0200
Subject: [PATCH 08/37] [BEEEP|PM-25164] Prevent memory dumping on renderer on
Linux (#16136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Implement libmemory_security
* Cleanup and add script
* Remove duplicate build for flatpak
* Rename to process isolation
* Move to desktop native
* Undo changes in gitignore
* Remove after-pack changes
* Run cargo fmt
* Sort deps
* Attempt to fix windows build
* Update apps/desktop/desktop_native/process_isolation/Cargo.toml
Co-authored-by: Daniel GarcÃa
* Revert "Remove after-pack changes"
This reverts commit c441025587629c2759adf523ffea46d95df4557f.
* Fix lib process isolation not being included in build
* Fix build
* Attempt to fix build
* Attempt to fix build
* Undo
* Fix library not being included
---------
Co-authored-by: Daniel GarcÃa
---
apps/desktop/desktop_native/Cargo.lock | 42 ++++++++++++++++-
apps/desktop/desktop_native/Cargo.toml | 2 +
apps/desktop/desktop_native/build.js | 17 +++++++
.../process_isolation/Cargo.toml | 14 ++++++
.../process_isolation/src/lib.rs | 46 +++++++++++++++++++
.../process_isolation/test_isolation.sh | 40 ++++++++++++++++
apps/desktop/electron-builder.json | 4 ++
apps/desktop/package.json | 3 +-
.../com.bitwarden.desktop.devel.yaml | 2 +
apps/desktop/resources/linux-wrapper.sh | 13 ++++--
10 files changed, 178 insertions(+), 5 deletions(-)
create mode 100644 apps/desktop/desktop_native/process_isolation/Cargo.toml
create mode 100644 apps/desktop/desktop_native/process_isolation/src/lib.rs
create mode 100644 apps/desktop/desktop_native/process_isolation/test_isolation.sh
diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock
index 9020e08362e..4fe1dc8cd8d 100644
--- a/apps/desktop/desktop_native/Cargo.lock
+++ b/apps/desktop/desktop_native/Cargo.lock
@@ -754,6 +754,22 @@ dependencies = [
"syn",
]
+[[package]]
+name = "ctor"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
+
[[package]]
name = "ctr"
version = "0.9.2"
@@ -1049,6 +1065,21 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+[[package]]
+name = "dtor"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1886,7 +1917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags",
- "ctor",
+ "ctor 0.2.9",
"napi-derive",
"napi-sys",
"once_cell",
@@ -2559,6 +2590,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "process_isolation"
+version = "0.0.0"
+dependencies = [
+ "ctor 0.5.0",
+ "desktop_core",
+ "libc",
+]
+
[[package]]
name = "quick-xml"
version = "0.37.5"
diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml
index 0a637b12de9..39c77f53254 100644
--- a/apps/desktop/desktop_native/Cargo.toml
+++ b/apps/desktop/desktop_native/Cargo.toml
@@ -6,6 +6,7 @@ members = [
"core",
"macos_provider",
"napi",
+ "process_isolation",
"proxy",
"windows_plugin_authenticator"
]
@@ -27,6 +28,7 @@ byteorder = "=1.5.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
core-foundation = "=0.10.1"
+ctor = "=0.5.0"
dirs = "=6.0.0"
ed25519 = "=2.2.3"
embed_plist = "=1.2.2"
diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js
index 125cb1bb567..8b13fcc6eb3 100644
--- a/apps/desktop/desktop_native/build.js
+++ b/apps/desktop/desktop_native/build.js
@@ -45,6 +45,20 @@ function buildProxyBin(target, release = true) {
}
}
+function buildProcessIsolation() {
+ if (process.platform !== "linux") {
+ return;
+ }
+
+ child_process.execSync(`cargo build --release`, {
+ stdio: 'inherit',
+ cwd: path.join(__dirname, "process_isolation")
+ });
+
+ console.log("Copying process isolation library to dist folder");
+ fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`));
+}
+
function installTarget(target) {
child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname });
}
@@ -53,6 +67,7 @@ if (!crossPlatform && !target) {
console.log(`Building native modules in ${mode} mode for the native architecture`);
buildNapiModule(false, mode === "release");
buildProxyBin(false, mode === "release");
+ buildProcessIsolation();
return;
}
@@ -61,6 +76,7 @@ if (target) {
installTarget(target);
buildNapiModule(target, mode === "release");
buildProxyBin(target, mode === "release");
+ buildProcessIsolation();
return;
}
@@ -78,4 +94,5 @@ platformTargets.forEach(([target, _]) => {
installTarget(target);
buildNapiModule(target);
buildProxyBin(target);
+ buildProcessIsolation();
});
diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml
new file mode 100644
index 00000000000..8e4072f1a90
--- /dev/null
+++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "process_isolation"
+edition = { workspace = true }
+license = { workspace = true }
+version = { workspace = true }
+publish = { workspace = true }
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+ctor = { workspace = true }
+desktop_core = { path = "../core" }
+libc = { workspace = true }
diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs
new file mode 100644
index 00000000000..57275817b9f
--- /dev/null
+++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs
@@ -0,0 +1,46 @@
+#![cfg(target_os = "linux")]
+
+//! This library compiles to a pre-loadable shared object. When preloaded, it
+//! immediately isolates the process using the methods available on the platform.
+//! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env
+//! from being read and the memory from being stolen.
+
+use desktop_core::process_isolation;
+use std::{ffi::c_char, sync::LazyLock};
+
+static ORIGINAL_UNSETENV: LazyLock i32> =
+ LazyLock::new(|| unsafe {
+ std::mem::transmute(libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()))
+ });
+
+/// Hooks unsetenv to fix a bug in zypak-wrapper.
+/// Zypak unsets the env in Flatpak as a side-effect, which means that only the top level
+/// processes would be hooked. With this work-around all processes in the tree are hooked
+#[unsafe(no_mangle)]
+unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 {
+ unsafe {
+ let Ok(name_str) = std::ffi::CStr::from_ptr(name).to_str() else {
+ return ORIGINAL_UNSETENV(name);
+ };
+
+ if name_str == "LD_PRELOAD" {
+ // This env variable is provided by the flatpak configuration
+ let ld_preload = std::env::var("PROCESS_ISOLATION_LD_PRELOAD").unwrap_or_default();
+ std::env::set_var("LD_PRELOAD", ld_preload);
+ return 0;
+ }
+
+ ORIGINAL_UNSETENV(name)
+ }
+}
+
+// Hooks the shared object being loaded into the process
+#[ctor::ctor]
+fn preload_init() {
+ let pid = unsafe { libc::getpid() };
+ unsafe {
+ println!("[Process Isolation] Enabling memory security for process {pid}");
+ process_isolation::isolate_process();
+ process_isolation::disable_coredumps();
+ }
+}
diff --git a/apps/desktop/desktop_native/process_isolation/test_isolation.sh b/apps/desktop/desktop_native/process_isolation/test_isolation.sh
new file mode 100644
index 00000000000..91f3b7933df
--- /dev/null
+++ b/apps/desktop/desktop_native/process_isolation/test_isolation.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# This script tests the memory isolation status of bitwarden-desktop processes. The script will print "isolated"
+# if the memory is not accessible by other processes.
+
+CURRENT_USER=$(whoami)
+
+# Find processes with "bitwarden" in the command
+pids=$(pgrep -f bitwarden)
+
+if [[ -z "$pids" ]]; then
+ echo "No bitwarden processes found."
+ exit 0
+fi
+
+for pid in $pids; do
+ # Get process info: command, PPID, RSS memory
+ read cmd ppid rss <<<$(ps -o comm=,ppid=,rss= -p "$pid")
+
+ # Explicitly skip if the command line does not contain "bitwarden"
+ if ! grep -q "bitwarden" <<<"$cmd"; then
+ continue
+ fi
+
+ # Check ownership of /proc/$pid/environ
+ owner=$(stat -c "%U" /proc/$pid/environ 2>/dev/null)
+
+ if [[ "$owner" == "root" ]]; then
+ status="isolated"
+ elif [[ "$owner" == "$CURRENT_USER" ]]; then
+ status="insecure"
+ else
+ status="unknown-owner:$owner"
+ fi
+
+ # Convert memory to MB
+ mem_mb=$((rss / 1024))
+
+ echo "PID: $pid | CMD: $cmd | Mem: ${mem_mb}MB | Owner: $owner | Status: $status"
+done
diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json
index 2e780bf6b1d..f7dcfb65044 100644
--- a/apps/desktop/electron-builder.json
+++ b/apps/desktop/electron-builder.json
@@ -106,6 +106,10 @@
{
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}",
"to": "desktop_proxy"
+ },
+ {
+ "from": "desktop_native/dist/libprocess_isolation.so",
+ "to": "libprocess_isolation.so"
}
],
"target": ["deb", "freebsd", "rpm", "AppImage", "snap"],
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ee987905980..26d48742b7c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -35,9 +35,10 @@
"build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch",
"electron": "node ./scripts/start.js",
"electron:ignore": "node ./scripts/start.js --ignore-certificate-errors",
+ "flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop",
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
- "pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
+ "pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
diff --git a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml
index 858fb6e1af2..e72df98e22b 100644
--- a/apps/desktop/resources/com.bitwarden.desktop.devel.yaml
+++ b/apps/desktop/resources/com.bitwarden.desktop.devel.yaml
@@ -46,4 +46,6 @@ modules:
commands:
- ulimit -c 0
- export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
+ - export ZYPAK_LD_PRELOAD="/app/bin/libprocess_isolation.so"
+ - export PROCESS_ISOLATION_LD_PRELOAD="/app/bin/libprocess_isolation.so"
- exec zypak-wrapper /app/bin/bitwarden-app "$@"
diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh
index dd53eb9811c..50a323e1c18 100644
--- a/apps/desktop/resources/linux-wrapper.sh
+++ b/apps/desktop/resources/linux-wrapper.sh
@@ -7,12 +7,19 @@ ulimit -c 0
RAW_PATH=$(readlink -f "$0")
APP_PATH=$(dirname $RAW_PATH)
-# force use of base image libdus in snap
-if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]
-then
+# force use of base image libdbus in snap
+if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3"
fi
+# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD
+# This prevents debugger / memory dumping on all desktop processes
+if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then
+ LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so"
+ LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}"
+ export LD_PRELOAD
+fi
+
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
if [ "$USE_X11" = "true" ]; then
PARAMS=""
From 6ee41343a59e22662ad3f52a9a4d5757c0ad5e71 Mon Sep 17 00:00:00 2001
From: Kyle Denney <4227399+kdenney@users.noreply.github.com>
Date: Mon, 13 Oct 2025 10:49:52 -0500
Subject: [PATCH 09/37] [PM-25379] Refactor org metadata (#16759)
* removing unused properties from org metadata
* removing further properties from the response and replacing them with data already available
* [PM-25379] new org metadata service for new endpoint
* don't need strict ignore
* forgot unit tests
* added cache busting to metadata service
not used yet - waiting for a decision on moving a portion of this to AC
---
.../members/members.component.ts | 4 +-
...ganization-subscription-cloud.component.ts | 10 +-
.../member-access-report.component.ts | 6 +-
.../src/services/jslib-services.module.ts | 7 +
.../billing-api.service.abstraction.ts | 7 +-
...ganization-metadata.service.abstraction.ts | 12 +
.../organization-billing-metadata.response.ts | 23 --
.../billing/services/billing-api.service.ts | 17 +-
.../organization-metadata.service.spec.ts | 276 ++++++++++++++++++
.../organization-metadata.service.ts | 74 +++++
libs/common/src/enums/feature-flag.enum.ts | 2 +
11 files changed, 404 insertions(+), 34 deletions(-)
create mode 100644 libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts
create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.spec.ts
create mode 100644 libs/common/src/billing/services/organization/organization-metadata.service.ts
diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts
index b31f1cbf358..dd1c0edf08b 100644
--- a/apps/web/src/app/admin-console/organizations/members/members.component.ts
+++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts
@@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
+import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent
private groupService: GroupApiService,
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
+ private organizationMetadataService: OrganizationMetadataServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
@@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
switchMap(([_, organization]) =>
- this.billingApiService.getOrganizationBillingMetadata(organization.id),
+ this.organizationMetadataService.getOrganizationMetadata$(organization.id),
),
takeUntilDestroyed(),
shareReplay({ bufferSize: 1, refCount: false }),
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts
index 6bb262152ed..79d4057fdd7 100644
--- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts
+++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts
@@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner;
const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser;
- const metadata = await this.billingApiService.getOrganizationBillingMetadata(
- this.organizationId,
- );
-
this.organizationIsManagedByConsolidatedBillingMSP =
- this.userOrg.hasProvider && metadata.isManaged;
+ this.userOrg.hasProvider && this.userOrg.hasBillableProvider;
this.showSubscription =
isIndependentOrganizationOwner ||
isResoldOrganizationOwner ||
(isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP);
- this.showSelfHost = metadata.isEligibleForSelfHost;
+ this.showSelfHost =
+ this.userOrg.productTierType === ProductTierType.Families ||
+ this.userOrg.productTierType === ProductTierType.Enterprise;
if (this.showSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
index b9cab679560..796cf212a67 100644
--- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
+++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts
@@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
+import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
@@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit {
protected dialogService: DialogService,
protected userNamePipe: UserNamePipe,
protected billingApiService: BillingApiServiceAbstraction,
+ protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {
// Connect the search input to the table dataSource filter input
this.searchControl.valueChanges
@@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit {
const params = await firstValueFrom(this.route.params);
this.organizationId = params.organizationId;
- const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata(
- this.organizationId,
+ const billingMetadata = await firstValueFrom(
+ this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
);
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 717a6c501c9..c66c74a3ea9 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -145,12 +145,14 @@ import {
} from "@bitwarden/common/billing/abstractions";
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
+import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
+import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
@@ -1414,6 +1416,11 @@ const safeProviders: SafeProvider[] = [
useClass: BillingApiService,
deps: [ApiServiceAbstraction],
}),
+ safeProvider({
+ provide: OrganizationMetadataServiceAbstraction,
+ useClass: DefaultOrganizationMetadataService,
+ deps: [BillingApiServiceAbstraction, ConfigService],
+ }),
safeProvider({
provide: BillingAccountProfileStateService,
useClass: DefaultBillingAccountProfileStateService,
diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts
index b5695e2e8a0..1dbb8053e97 100644
--- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts
+++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts
@@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
import { PlanResponse } from "../../billing/models/response/plan.response";
import { ListResponse } from "../../models/response/list.response";
+import { OrganizationId } from "../../types/guid";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
import { InvoicesResponse } from "../models/response/invoices.response";
@@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction {
): Promise;
abstract getOrganizationBillingMetadata(
- organizationId: string,
+ organizationId: OrganizationId,
+ ): Promise;
+
+ abstract getOrganizationBillingMetadataVNext(
+ organizationId: OrganizationId,
): Promise;
abstract getPlans(): Promise>;
diff --git a/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts
new file mode 100644
index 00000000000..c16d4112273
--- /dev/null
+++ b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts
@@ -0,0 +1,12 @@
+import { Observable } from "rxjs";
+
+import { OrganizationId } from "../../types/guid";
+import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
+
+export abstract class OrganizationMetadataServiceAbstraction {
+ abstract getOrganizationMetadata$(
+ organizationId: OrganizationId,
+ ): Observable;
+
+ abstract refreshMetadataCache(): void;
+}
diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
index aa34c37bd1d..366d10a1dca 100644
--- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
+++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts
@@ -1,35 +1,12 @@
import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationBillingMetadataResponse extends BaseResponse {
- isEligibleForSelfHost: boolean;
- isManaged: boolean;
isOnSecretsManagerStandalone: boolean;
- isSubscriptionUnpaid: boolean;
- hasSubscription: boolean;
- hasOpenInvoice: boolean;
- invoiceDueDate: Date | null;
- invoiceCreatedDate: Date | null;
- subPeriodEndDate: Date | null;
- isSubscriptionCanceled: boolean;
organizationOccupiedSeats: number;
constructor(response: any) {
super(response);
- this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
- this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
- this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
- this.hasSubscription = this.getResponseProperty("HasSubscription");
- this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
-
- this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
- this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
- this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
- this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
}
-
- private parseDate(dateString: any): Date | null {
- return dateString ? new Date(dateString) : null;
- }
}
diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts
index a34809e9f02..c953d920055 100644
--- a/libs/common/src/billing/services/billing-api.service.ts
+++ b/libs/common/src/billing/services/billing-api.service.ts
@@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ListResponse } from "../../models/response/list.response";
+import { OrganizationId } from "../../types/guid";
import { BillingApiServiceAbstraction } from "../abstractions";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
@@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
}
async getOrganizationBillingMetadata(
- organizationId: string,
+ organizationId: OrganizationId,
): Promise {
const r = await this.apiService.send(
"GET",
@@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
+ async getOrganizationBillingMetadataVNext(
+ organizationId: OrganizationId,
+ ): Promise {
+ const r = await this.apiService.send(
+ "GET",
+ "/organizations/" + organizationId + "/billing/vnext/metadata",
+ null,
+ true,
+ true,
+ );
+
+ return new OrganizationBillingMetadataResponse(r);
+ }
+
async getPlans(): Promise> {
const r = await this.apiService.send("GET", "/plans", null, false, true);
return new ListResponse(r, PlanResponse);
diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts
new file mode 100644
index 00000000000..0ed60bef605
--- /dev/null
+++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts
@@ -0,0 +1,276 @@
+import { mock } from "jest-mock-extended";
+import { BehaviorSubject, firstValueFrom } from "rxjs";
+
+import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
+import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { newGuid } from "@bitwarden/guid";
+
+import { FeatureFlag } from "../../../enums/feature-flag.enum";
+import { OrganizationId } from "../../../types/guid";
+
+import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
+
+describe("DefaultOrganizationMetadataService", () => {
+ let service: DefaultOrganizationMetadataService;
+ let billingApiService: jest.Mocked;
+ let configService: jest.Mocked;
+ let featureFlagSubject: BehaviorSubject;
+
+ const mockOrganizationId = newGuid() as OrganizationId;
+ const mockOrganizationId2 = newGuid() as OrganizationId;
+
+ const createMockMetadataResponse = (
+ isOnSecretsManagerStandalone = false,
+ organizationOccupiedSeats = 5,
+ ): OrganizationBillingMetadataResponse => {
+ return {
+ isOnSecretsManagerStandalone,
+ organizationOccupiedSeats,
+ } as OrganizationBillingMetadataResponse;
+ };
+
+ beforeEach(() => {
+ billingApiService = mock();
+ configService = mock();
+ featureFlagSubject = new BehaviorSubject(false);
+
+ configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
+
+ service = new DefaultOrganizationMetadataService(billingApiService, configService);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ featureFlagSubject.complete();
+ });
+
+ describe("getOrganizationMetadata$", () => {
+ describe("feature flag OFF", () => {
+ beforeEach(() => {
+ featureFlagSubject.next(false);
+ });
+
+ it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
+ const mockResponse = createMockMetadataResponse(false, 10);
+ billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
+
+ const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+
+ expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
+ FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
+ );
+ expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
+ mockOrganizationId,
+ );
+ expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
+ expect(result).toEqual(mockResponse);
+ });
+
+ it("does not cache metadata when feature flag is off", async () => {
+ const mockResponse1 = createMockMetadataResponse(false, 10);
+ const mockResponse2 = createMockMetadataResponse(false, 15);
+ billingApiService.getOrganizationBillingMetadata
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2);
+
+ const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+ const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+
+ expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
+ expect(result1).toEqual(mockResponse1);
+ expect(result2).toEqual(mockResponse2);
+ });
+ });
+
+ describe("feature flag ON", () => {
+ beforeEach(() => {
+ featureFlagSubject.next(true);
+ });
+
+ it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
+ const mockResponse = createMockMetadataResponse(true, 15);
+ billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
+
+ const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+
+ expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
+ FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
+ );
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
+ mockOrganizationId,
+ );
+ expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
+ expect(result).toEqual(mockResponse);
+ });
+
+ it("caches metadata by organization ID when feature flag is on", async () => {
+ const mockResponse = createMockMetadataResponse(true, 10);
+ billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
+
+ const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+ const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
+ expect(result1).toEqual(mockResponse);
+ expect(result2).toEqual(mockResponse);
+ });
+
+ it("maintains separate cache entries for different organization IDs", async () => {
+ const mockResponse1 = createMockMetadataResponse(true, 10);
+ const mockResponse2 = createMockMetadataResponse(false, 20);
+ billingApiService.getOrganizationBillingMetadataVNext
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2);
+
+ const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+ const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
+ const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
+ const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
+
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
+ 1,
+ mockOrganizationId,
+ );
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
+ 2,
+ mockOrganizationId2,
+ );
+ expect(result1).toEqual(mockResponse1);
+ expect(result2).toEqual(mockResponse2);
+ expect(result3).toEqual(mockResponse1);
+ expect(result4).toEqual(mockResponse2);
+ });
+ });
+
+ describe("shareReplay behavior", () => {
+ beforeEach(() => {
+ featureFlagSubject.next(true);
+ });
+
+ it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
+ const mockResponse = createMockMetadataResponse(true, 10);
+ billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
+
+ const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
+
+ const subscription1Promise = firstValueFrom(metadata$);
+ const subscription2Promise = firstValueFrom(metadata$);
+ const subscription3Promise = firstValueFrom(metadata$);
+
+ const [result1, result2, result3] = await Promise.all([
+ subscription1Promise,
+ subscription2Promise,
+ subscription3Promise,
+ ]);
+
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
+ expect(result1).toEqual(mockResponse);
+ expect(result2).toEqual(mockResponse);
+ expect(result3).toEqual(mockResponse);
+ });
+ });
+ });
+
+ describe("refreshMetadataCache", () => {
+ beforeEach(() => {
+ featureFlagSubject.next(true);
+ });
+
+ it("refreshes cached metadata when called with feature flag on", (done) => {
+ const mockResponse1 = createMockMetadataResponse(true, 10);
+ const mockResponse2 = createMockMetadataResponse(true, 20);
+ let invocationCount = 0;
+
+ billingApiService.getOrganizationBillingMetadataVNext
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2);
+
+ const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
+ next: (result) => {
+ invocationCount++;
+
+ if (invocationCount === 1) {
+ expect(result).toEqual(mockResponse1);
+ } else if (invocationCount === 2) {
+ expect(result).toEqual(mockResponse2);
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
+ subscription.unsubscribe();
+ done();
+ }
+ },
+ error: done.fail,
+ });
+
+ setTimeout(() => {
+ service.refreshMetadataCache();
+ }, 10);
+ });
+
+ it("does not trigger refresh when feature flag is disabled", async () => {
+ featureFlagSubject.next(false);
+
+ const mockResponse1 = createMockMetadataResponse(false, 10);
+ const mockResponse2 = createMockMetadataResponse(false, 20);
+ let invocationCount = 0;
+
+ billingApiService.getOrganizationBillingMetadata
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2);
+
+ const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
+ next: () => {
+ invocationCount++;
+ },
+ });
+
+ // wait for initial invocation
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(invocationCount).toBe(1);
+
+ service.refreshMetadataCache();
+
+ // wait to ensure no additional invocations
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(invocationCount).toBe(1);
+ expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
+
+ subscription.unsubscribe();
+ });
+
+ it("bypasses cache when refreshing metadata", (done) => {
+ const mockResponse1 = createMockMetadataResponse(true, 10);
+ const mockResponse2 = createMockMetadataResponse(true, 20);
+ const mockResponse3 = createMockMetadataResponse(true, 30);
+ let invocationCount = 0;
+
+ billingApiService.getOrganizationBillingMetadataVNext
+ .mockResolvedValueOnce(mockResponse1)
+ .mockResolvedValueOnce(mockResponse2)
+ .mockResolvedValueOnce(mockResponse3);
+
+ const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
+ next: (result) => {
+ invocationCount++;
+
+ if (invocationCount === 1) {
+ expect(result).toEqual(mockResponse1);
+ service.refreshMetadataCache();
+ } else if (invocationCount === 2) {
+ expect(result).toEqual(mockResponse2);
+ service.refreshMetadataCache();
+ } else if (invocationCount === 3) {
+ expect(result).toEqual(mockResponse3);
+ expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
+ subscription.unsubscribe();
+ done();
+ }
+ },
+ error: done.fail,
+ });
+ });
+ });
+});
diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts
new file mode 100644
index 00000000000..09aaa202112
--- /dev/null
+++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts
@@ -0,0 +1,74 @@
+import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs";
+
+import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
+
+import { FeatureFlag } from "../../../enums/feature-flag.enum";
+import { ConfigService } from "../../../platform/abstractions/config/config.service";
+import { OrganizationId } from "../../../types/guid";
+import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction";
+import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response";
+
+export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction {
+ private metadataCache = new Map<
+ OrganizationId,
+ Observable
+ >();
+
+ constructor(
+ private billingApiService: BillingApiServiceAbstraction,
+ private configService: ConfigService,
+ ) {}
+ private refreshMetadataTrigger = new Subject();
+
+ refreshMetadataCache = () => this.refreshMetadataTrigger.next();
+
+ getOrganizationMetadata$ = (
+ organizationId: OrganizationId,
+ ): Observable =>
+ this.configService
+ .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure)
+ .pipe(
+ switchMap((featureFlagEnabled) => {
+ return merge(
+ this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled),
+ this.refreshMetadataTrigger.pipe(
+ filter(() => featureFlagEnabled),
+ switchMap(() =>
+ this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true),
+ ),
+ ),
+ );
+ }),
+ );
+
+ private getOrganizationMetadataInternal$(
+ organizationId: OrganizationId,
+ featureFlagEnabled: boolean,
+ bypassCache: boolean = false,
+ ): Observable {
+ if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) {
+ return this.metadataCache.get(organizationId)!;
+ }
+
+ const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe(
+ shareReplay({ bufferSize: 1, refCount: false }),
+ );
+
+ if (featureFlagEnabled) {
+ this.metadataCache.set(organizationId, metadata$);
+ }
+
+ return metadata$;
+ }
+
+ private async fetchMetadata(
+ organizationId: OrganizationId,
+ featureFlagEnabled: boolean,
+ ): Promise {
+ if (featureFlagEnabled) {
+ return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId);
+ }
+
+ return await this.billingApiService.getOrganizationBillingMetadata(organizationId);
+ }
+}
diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts
index 8b73010daf5..78113d74cb8 100644
--- a/libs/common/src/enums/feature-flag.enum.ts
+++ b/libs/common/src/enums/feature-flag.enum.ts
@@ -25,6 +25,7 @@ export enum FeatureFlag {
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
+ PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
/* Key Management */
@@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
+ [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
/* Key Management */
From 2c08af2b42ee82fcc2b9648b6ee0d7d60392248d Mon Sep 17 00:00:00 2001
From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
Date: Mon, 13 Oct 2025 17:01:16 +0100
Subject: [PATCH 10/37] [PM-25166]Hide and Show Old Premium Banner Depending On
the Feature Flag (#16684)
* Code to hide and show the premium banner
* add the right flag name
* Removed unused flag
* Remove the unused feature flag
* Resolve the flag name issue
---
.../vault-banners.component.spec.ts | 42 +++++++++++++++++--
.../vault-banners/vault-banners.component.ts | 17 +++++++-
2 files changed, 54 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
index f3a859e17bf..7730ab974fb 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts
@@ -8,6 +8,7 @@ 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -29,6 +30,7 @@ describe("VaultBannersComponent", () => {
let messageSubject: Subject<{ command: string }>;
const premiumBanner$ = new BehaviorSubject(false);
const pendingAuthRequest$ = new BehaviorSubject(false);
+ const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject(false);
const mockUserId = Utils.newGuid() as UserId;
const bannerService = mock({
@@ -88,7 +90,14 @@ describe("VaultBannersComponent", () => {
},
{
provide: ConfigService,
- useValue: mock(),
+ useValue: mock({
+ getFeatureFlag$: jest.fn((flag: FeatureFlag) => {
+ if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) {
+ return PM24996_ImplementUpgradeFromFreeDialogFlag$;
+ }
+ return new BehaviorSubject(false);
+ }),
+ }),
},
],
})
@@ -104,8 +113,14 @@ describe("VaultBannersComponent", () => {
});
describe("premiumBannerVisible$", () => {
- it("shows premium banner", async () => {
+ beforeEach(() => {
+ // Reset feature flag to default (false) before each test
+ PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
+ });
+
+ it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => {
premiumBanner$.next(true);
+ PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
fixture.detectChanges();
@@ -113,8 +128,29 @@ describe("VaultBannersComponent", () => {
expect(banner.componentInstance.bannerType()).toBe("premium");
});
- it("dismisses premium banner", async () => {
+ it("hides premium banner when feature flag is enabled", async () => {
+ premiumBanner$.next(true);
+ PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
+
+ fixture.detectChanges();
+
+ const banner = fixture.debugElement.query(By.directive(BannerComponent));
+ expect(banner).toBeNull();
+ });
+
+ it("dismisses premium banner when shouldShowPremiumBanner is false", async () => {
premiumBanner$.next(false);
+ PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
+
+ fixture.detectChanges();
+
+ const banner = fixture.debugElement.query(By.directive(BannerComponent));
+ expect(banner).toBeNull();
+ });
+
+ it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => {
+ premiumBanner$.next(false);
+ PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
fixture.detectChanges();
diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
index cd32eaf2858..78624b3662c 100644
--- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts
@@ -1,10 +1,12 @@
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
-import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
+import { combineLatest, filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { BannerModule } from "@bitwarden/components";
@@ -39,10 +41,21 @@ export class VaultBannersComponent implements OnInit {
private router: Router,
private accountService: AccountService,
private messageListener: MessageListener,
+ private configService: ConfigService,
) {
this.premiumBannerVisible$ = this.activeUserId$.pipe(
filter((userId): userId is UserId => userId != null),
- switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)),
+ switchMap((userId) =>
+ combineLatest([
+ this.vaultBannerService.shouldShowPremiumBanner$(userId),
+ this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
+ ]).pipe(
+ map(
+ ([shouldShowBanner, PM24996_ImplementUpgradeFromFreeDialogEnabled]) =>
+ shouldShowBanner && !PM24996_ImplementUpgradeFromFreeDialogEnabled,
+ ),
+ ),
+ ),
);
// Listen for auth request messages and show banner immediately
From 886003ba88a51b9672f478894de5a6b1da16b140 Mon Sep 17 00:00:00 2001
From: Dave <3836813+enmande@users.noreply.github.com>
Date: Mon, 13 Oct 2025 12:48:25 -0400
Subject: [PATCH 11/37] feat(two-factor-api-service) [PM-26465]: (Refactor)
Two-Factor API Service (#16747)
* feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiServiceAbstraction.
* feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiService implementation.
* feat(two-factor-api-service) [PM-26465]: Add test suite for TwoFactorApiService.
* feat(two-factor-api-service) [PM-26465]: Replace ApiService dependencies with TwoFactorApiService for all refactored methods.
* feat(two-factor-api-service) [PM-26465]: Finish removal of Two-Factor API methods from ApiService.
* fix(two-factor-api-service) [PM-26465]: Correct endpoint spelling.
* feat(two-factor-api-service) [PM-26465]: Update dependency support for CLI.
* fix(two-factor-api-service) [PM-26465]: Update tests/deps for corrected spelling.
* feat(two-factor-api-service) [PM-26465]: Add TwoFactorApiService to Browser services module.
* fix(two-factor-api-service) [PM-26465]: Re-spell dependencies to take *Abstraction throughout, move to JslibServices module for cleaner importing across clients.
* feat(two-factor-api-service) [PM-26465]: Move new services to a feature area, rename abstract and concrete/default.
* feat(two-factor-api-service) [PM-26465]: Move the feature area to common/auth, not auth/common.
* feat(two-factor-api-service) [PM-26465]: Remove now-unneeded include from auth/tsconfig.
---
apps/cli/src/auth/commands/login.command.ts | 6 +-
apps/cli/src/program.ts | 2 +-
.../service-container/service-container.ts | 4 +
.../settings/two-factor-setup.component.ts | 8 +-
.../account/change-email.component.spec.ts | 6 +-
.../account/change-email.component.ts | 4 +-
...account-verify-devices-dialog.component.ts | 6 +-
...wo-factor-setup-authenticator.component.ts | 10 +-
.../two-factor-setup-duo.component.ts | 13 +-
.../two-factor-setup-email.component.ts | 10 +-
.../two-factor-setup-method-base.component.ts | 15 +-
.../two-factor-setup-webauthn.component.ts | 12 +-
.../two-factor-setup-yubikey.component.ts | 8 +-
.../two-factor/two-factor-setup.component.ts | 6 +-
.../two-factor/two-factor-verify.component.ts | 18 +-
.../src/services/jslib-services.module.ts | 6 +
.../two-factor-auth-email.component.ts | 6 +-
libs/common/src/abstractions/api.service.ts | 81 --
.../request/device-verification.request.ts | 7 -
.../response/device-verification.response.ts | 16 -
.../default-two-factor-api.service.ts | 272 +++++++
libs/common/src/auth/two-factor/index.ts | 2 +
.../two-factor/two-factor-api.service.spec.ts | 697 ++++++++++++++++++
.../auth/two-factor/two-factor-api.service.ts | 292 ++++++++
libs/common/src/services/api.service.ts | 220 ------
25 files changed, 1344 insertions(+), 383 deletions(-)
delete mode 100644 libs/common/src/auth/models/request/device-verification.request.ts
delete mode 100644 libs/common/src/auth/models/response/device-verification.response.ts
create mode 100644 libs/common/src/auth/two-factor/default-two-factor-api.service.ts
create mode 100644 libs/common/src/auth/two-factor/index.ts
create mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
create mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.ts
diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts
index 7e5058dcff0..e1a3d123441 100644
--- a/apps/cli/src/auth/commands/login.command.ts
+++ b/apps/cli/src/auth/commands/login.command.ts
@@ -14,7 +14,6 @@ import {
SsoUrlService,
UserApiLoginCredentials,
} from "@bitwarden/auth/common";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
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";
@@ -29,6 +28,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
@@ -62,7 +62,7 @@ export class LoginCommand {
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected authService: AuthService,
- protected apiService: ApiService,
+ protected twoFactorApiService: TwoFactorApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
@@ -279,7 +279,7 @@ export class LoginCommand {
const emailReq = new TwoFactorEmailRequest();
emailReq.email = await this.loginStrategyService.getEmail();
emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
- await this.apiService.postTwoFactorEmail(emailReq);
+ await this.twoFactorApiService.postTwoFactorEmail(emailReq);
}
if (twoFactorToken == null) {
diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts
index 8f202bc0845..e8c9bff746e 100644
--- a/apps/cli/src/program.ts
+++ b/apps/cli/src/program.ts
@@ -175,7 +175,7 @@ export class Program extends BaseProgram {
const command = new LoginCommand(
this.serviceContainer.loginStrategyService,
this.serviceContainer.authService,
- this.serviceContainer.apiService,
+ this.serviceContainer.twoFactorApiService,
this.serviceContainer.masterPasswordApiService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.environmentService,
diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts
index d13d251bce0..7c4b708576b 100644
--- a/apps/cli/src/service-container/service-container.ts
+++ b/apps/cli/src/service-container/service-container.ts
@@ -49,6 +49,7 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
+import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -228,6 +229,7 @@ export class ServiceContainer {
tokenService: TokenService;
appIdService: AppIdService;
apiService: NodeApiService;
+ twoFactorApiService: TwoFactorApiService;
hibpApiService: HibpApiService;
environmentService: EnvironmentService;
cipherService: CipherService;
@@ -528,6 +530,8 @@ export class ServiceContainer {
this.configApiService = new ConfigApiService(this.apiService);
+ this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService);
+
this.authService = new AuthService(
this.accountService,
this.messagingService,
diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
index 020a16dd932..3151e0a702f 100644
--- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
+++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
@@ -5,7 +5,6 @@ import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs";
import { first, tap } from "rxjs/operators";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
@@ -15,6 +14,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -35,7 +35,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
tabbedHeader = false;
constructor(
dialogService: DialogService,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
messagingService: MessagingService,
policyService: PolicyService,
private route: ActivatedRoute,
@@ -47,7 +47,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
) {
super(
dialogService,
- apiService,
+ twoFactorApiService,
messagingService,
policyService,
billingAccountProfileStateService,
@@ -116,7 +116,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
}
protected getTwoFactorProviders() {
- return this.apiService.getTwoFactorOrganizationProviders(this.organizationId);
+ return this.twoFactorApiService.getTwoFactorOrganizationProviders(this.organizationId);
}
protected filterProvider(type: TwoFactorProviderType): boolean {
diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
index bd0d9df9f06..934de0f6453 100644
--- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
+++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
@@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -22,12 +23,14 @@ describe("ChangeEmailComponent", () => {
let fixture: ComponentFixture;
let apiService: MockProxy;
+ let twoFactorApiService: MockProxy;
let accountService: FakeAccountService;
let keyService: MockProxy;
let kdfConfigService: MockProxy;
beforeEach(async () => {
apiService = mock();
+ twoFactorApiService = mock();
keyService = mock();
kdfConfigService = mock();
accountService = mockAccountServiceWith("UserId" as UserId);
@@ -37,6 +40,7 @@ describe("ChangeEmailComponent", () => {
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: ApiService, useValue: apiService },
+ { provide: TwoFactorApiService, useValue: twoFactorApiService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: KeyService, useValue: keyService },
{ provide: MessagingService, useValue: mock() },
@@ -57,7 +61,7 @@ describe("ChangeEmailComponent", () => {
describe("ngOnInit", () => {
beforeEach(() => {
- apiService.getTwoFactorProviders.mockResolvedValue({
+ twoFactorApiService.getTwoFactorProviders.mockResolvedValue({
data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse],
} as ListResponse);
});
diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts
index a55846a5c0f..b6ca39c6413 100644
--- a/apps/web/src/app/auth/settings/account/change-email.component.ts
+++ b/apps/web/src/app/auth/settings/account/change-email.component.ts
@@ -8,6 +8,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -37,6 +38,7 @@ export class ChangeEmailComponent implements OnInit {
constructor(
private accountService: AccountService,
private apiService: ApiService,
+ private twoFactorApiService: TwoFactorApiService,
private i18nService: I18nService,
private keyService: KeyService,
private messagingService: MessagingService,
@@ -48,7 +50,7 @@ export class ChangeEmailComponent implements OnInit {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
- const twoFactorProviders = await this.apiService.getTwoFactorProviders();
+ const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders();
this.showTwoFactorEmailWarning = twoFactorProviders.data.some(
(p) => p.type === TwoFactorProviderType.Email && p.enabled,
);
diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts
index 63a26f08eee..c66f31f6c3b 100644
--- a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts
+++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts
@@ -5,11 +5,11 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -64,7 +64,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy
private userVerificationService: UserVerificationService,
private dialogRef: DialogRef,
private toastService: ToastService,
- private apiService: ApiService,
+ private twoFactorApiService: TwoFactorApiService,
) {
this.accountService.accountVerifyNewDeviceLogin$
.pipe(takeUntil(this.destroy$))
@@ -74,7 +74,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy
}
async ngOnInit() {
- const twoFactorProviders = await this.apiService.getTwoFactorProviders();
+ const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders();
this.has2faConfigured = twoFactorProviders.data.length > 0;
}
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts
index 698e0911b04..d57d6eca894 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts
@@ -6,13 +6,13 @@ import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angu
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -92,7 +92,7 @@ export class TwoFactorSetupAuthenticatorComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse,
private dialogRef: DialogRef,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
userVerificationService: UserVerificationService,
private formBuilder: FormBuilder,
@@ -104,7 +104,7 @@ export class TwoFactorSetupAuthenticatorComponent
protected toastService: ToastService,
) {
super(
- apiService,
+ twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -154,7 +154,7 @@ export class TwoFactorSetupAuthenticatorComponent
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
- const response = await this.apiService.putTwoFactorAuthenticator(request);
+ const response = await this.twoFactorApiService.putTwoFactorAuthenticator(request);
await this.processResponse(response);
this.onUpdated.emit(true);
}
@@ -174,7 +174,7 @@ export class TwoFactorSetupAuthenticatorComponent
request.type = this.type;
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
- await this.apiService.deleteTwoFactorAuthenticator(request);
+ await this.twoFactorApiService.deleteTwoFactorAuthenticator(request);
this.enabled = false;
this.toastService.showToast({
variant: "success",
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts
index 0efd0c79b4e..bf820e32917 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts
@@ -2,11 +2,11 @@ import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -63,7 +63,7 @@ export class TwoFactorSetupDuoComponent
constructor(
@Inject(DIALOG_DATA) protected data: TwoFactorDuoComponentConfig,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -74,7 +74,7 @@ export class TwoFactorSetupDuoComponent
protected toastService: ToastService,
) {
super(
- apiService,
+ twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -139,9 +139,12 @@ export class TwoFactorSetupDuoComponent
let response: TwoFactorDuoResponse;
if (this.organizationId != null) {
- response = await this.apiService.putTwoFactorOrganizationDuo(this.organizationId, request);
+ response = await this.twoFactorApiService.putTwoFactorOrganizationDuo(
+ this.organizationId,
+ request,
+ );
} else {
- response = await this.apiService.putTwoFactorDuo(request);
+ response = await this.twoFactorApiService.putTwoFactorDuo(request);
}
this.processResponse(response);
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts
index 544f3850ea6..138d541d551 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts
@@ -3,13 +3,13 @@ import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -66,7 +66,7 @@ export class TwoFactorSetupEmailComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -78,7 +78,7 @@ export class TwoFactorSetupEmailComponent
protected toastService: ToastService,
) {
super(
- apiService,
+ twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -131,7 +131,7 @@ export class TwoFactorSetupEmailComponent
sendEmail = async () => {
const request = await this.buildRequestModel(TwoFactorEmailRequest);
request.email = this.email;
- this.emailPromise = this.apiService.postTwoFactorEmailSetup(request);
+ this.emailPromise = this.twoFactorApiService.postTwoFactorEmailSetup(request);
await this.emailPromise;
this.sentEmail = this.email;
};
@@ -141,7 +141,7 @@ export class TwoFactorSetupEmailComponent
request.email = this.email;
request.token = this.token;
- const response = await this.apiService.putTwoFactorEmail(request);
+ const response = await this.twoFactorApiService.putTwoFactorEmail(request);
await this.processResponse(response);
this.onUpdated.emit(true);
}
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts
index 7569577e781..aa3b9e1def3 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts
@@ -1,11 +1,11 @@
import { Directive, EventEmitter, Output } from "@angular/core";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponseBase } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -30,7 +30,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
protected componentName = "";
constructor(
- protected apiService: ApiService,
+ protected twoFactorApiService: TwoFactorApiService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
@@ -77,9 +77,12 @@ export abstract class TwoFactorSetupMethodBaseComponent {
}
request.type = this.type;
if (this.organizationId != null) {
- promise = this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request);
+ promise = this.twoFactorApiService.putTwoFactorOrganizationDisable(
+ this.organizationId,
+ request,
+ );
} else {
- promise = this.apiService.putTwoFactorDisable(request);
+ promise = this.twoFactorApiService.putTwoFactorDisable(request);
}
await promise;
this.enabled = false;
@@ -111,9 +114,9 @@ export abstract class TwoFactorSetupMethodBaseComponent {
}
request.type = this.type;
if (this.organizationId != null) {
- await this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request);
+ await this.twoFactorApiService.putTwoFactorOrganizationDisable(this.organizationId, request);
} else {
- await this.apiService.putTwoFactorDisable(request);
+ await this.twoFactorApiService.putTwoFactorDisable(request);
}
this.enabled = false;
this.toastService.showToast({
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts
index 66cd3596063..ff0e971461e 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts
@@ -3,7 +3,6 @@ import { Component, Inject, NgZone } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
@@ -13,6 +12,7 @@ import {
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -79,7 +79,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse,
private dialogRef: DialogRef,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
private ngZone: NgZone,
@@ -89,7 +89,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
toastService: ToastService,
) {
super(
- apiService,
+ twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -127,7 +127,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
request.id = this.keyIdAvailable;
request.name = this.formGroup.value.name || "";
- const response = await this.apiService.putTwoFactorWebAuthn(request);
+ const response = await this.twoFactorApiService.putTwoFactorWebAuthn(request);
this.processResponse(response);
this.toastService.showToast({
title: this.i18nService.t("success"),
@@ -163,7 +163,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
const request = await this.buildRequestModel(UpdateTwoFactorWebAuthnDeleteRequest);
request.id = key.id;
try {
- key.removePromise = this.apiService.deleteTwoFactorWebAuthn(request);
+ key.removePromise = this.twoFactorApiService.deleteTwoFactorWebAuthn(request);
const response = await key.removePromise;
key.removePromise = null;
await this.processResponse(response);
@@ -177,7 +177,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
return;
}
const request = await this.buildRequestModel(SecretVerificationRequest);
- this.challengePromise = this.apiService.getTwoFactorWebAuthnChallenge(request);
+ this.challengePromise = this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
const challenge = await this.challengePromise;
this.readDevice(challenge);
};
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts
index 0b85d219928..4e4691a5f60 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts
@@ -9,11 +9,11 @@ import {
} from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -93,7 +93,7 @@ export class TwoFactorSetupYubiKeyComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse,
- apiService: ApiService,
+ twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -103,7 +103,7 @@ export class TwoFactorSetupYubiKeyComponent
protected toastService: ToastService,
) {
super(
- apiService,
+ twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -176,7 +176,7 @@ export class TwoFactorSetupYubiKeyComponent
request.key5 = keys != null && keys.length > 4 ? (keys[4]?.key ?? "") : "";
request.nfc = this.formGroup.value.anyKeyHasNfc ?? false;
- this.processResponse(await this.apiService.putTwoFactorYubiKey(request));
+ this.processResponse(await this.twoFactorApiService.putTwoFactorYubiKey(request));
this.refreshFormArrayData();
this.toastService.showToast({
title: this.i18nService.t("success"),
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts
index 043c27998cd..c3a55ad661e 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts
@@ -13,7 +13,6 @@ import {
} from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -26,6 +25,7 @@ import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/respons
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
@@ -68,7 +68,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
constructor(
protected dialogService: DialogService,
- protected apiService: ApiService,
+ protected twoFactorApiService: TwoFactorApiService,
protected messagingService: MessagingService,
protected policyService: PolicyService,
billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -270,7 +270,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
}
protected getTwoFactorProviders() {
- return this.apiService.getTwoFactorProviders();
+ return this.twoFactorApiService.getTwoFactorProviders();
}
protected filterProvider(type: TwoFactorProviderType): boolean {
diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts
index 07939db7eff..a2c734ed2d5 100644
--- a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts
+++ b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts
@@ -2,11 +2,11 @@ import { Component, EventEmitter, Inject, Output } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response";
import { Verification } from "@bitwarden/common/auth/types/verification";
@@ -55,7 +55,7 @@ export class TwoFactorVerifyComponent {
constructor(
@Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData,
private dialogRef: DialogRef,
- private apiService: ApiService,
+ private twoFactorApiService: TwoFactorApiService,
private i18nService: I18nService,
private userVerificationService: UserVerificationService,
) {
@@ -116,22 +116,22 @@ export class TwoFactorVerifyComponent {
private apiCall(request: SecretVerificationRequest): Promise {
switch (this.type) {
case -1 as TwoFactorProviderType:
- return this.apiService.getTwoFactorRecover(request);
+ return this.twoFactorApiService.getTwoFactorRecover(request);
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.OrganizationDuo:
if (this.organizationId != null) {
- return this.apiService.getTwoFactorOrganizationDuo(this.organizationId, request);
+ return this.twoFactorApiService.getTwoFactorOrganizationDuo(this.organizationId, request);
} else {
- return this.apiService.getTwoFactorDuo(request);
+ return this.twoFactorApiService.getTwoFactorDuo(request);
}
case TwoFactorProviderType.Email:
- return this.apiService.getTwoFactorEmail(request);
+ return this.twoFactorApiService.getTwoFactorEmail(request);
case TwoFactorProviderType.WebAuthn:
- return this.apiService.getTwoFactorWebAuthn(request);
+ return this.twoFactorApiService.getTwoFactorWebAuthn(request);
case TwoFactorProviderType.Authenticator:
- return this.apiService.getTwoFactorAuthenticator(request);
+ return this.twoFactorApiService.getTwoFactorAuthenticator(request);
case TwoFactorProviderType.Yubikey:
- return this.apiService.getTwoFactorYubiKey(request);
+ return this.twoFactorApiService.getTwoFactorYubiKey(request);
default:
throw new Error(`Unknown two-factor type: ${this.type}`);
}
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index c66c74a3ea9..3f9657b8115 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -127,6 +127,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/services/user-ve
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
+import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -1519,6 +1520,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTwoFactorAuthWebAuthnComponentService,
deps: [],
}),
+ safeProvider({
+ provide: TwoFactorApiService,
+ useClass: DefaultTwoFactorApiService,
+ deps: [ApiServiceAbstraction],
+ }),
safeProvider({
provide: ViewCacheService,
useExisting: NoopViewCacheService,
diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts
index 9b402f3a956..084e8e6e851 100644
--- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts
+++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts
@@ -4,10 +4,10 @@ import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
-import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
+import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -62,7 +62,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
- protected apiService: ApiService,
+ protected twoFactorApiService: TwoFactorApiService,
protected appIdService: AppIdService,
private toastService: ToastService,
private cacheService: TwoFactorAuthEmailComponentCacheService,
@@ -131,7 +131,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
request.deviceIdentifier = await this.appIdService.getAppId();
request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? "";
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
- this.emailPromise = this.apiService.postTwoFactorEmail(request);
+ this.emailPromise = this.twoFactorApiService.postTwoFactorEmail(request);
await this.emailPromise;
this.emailSent = true;
diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts
index 1cab48148e9..93e47a6d9a8 100644
--- a/libs/common/src/abstractions/api.service.ts
+++ b/libs/common/src/abstractions/api.service.ts
@@ -37,8 +37,6 @@ import {
ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
-import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
-import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
import { EmailRequest } from "../auth/models/request/email.request";
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
@@ -48,34 +46,15 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
-import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
-import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
-import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
-import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
-import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
-import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request";
-import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request";
-import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request";
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
-import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
-import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response";
-import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response";
-import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response";
-import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response";
-import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response";
-import {
- ChallengeResponse,
- TwoFactorWebAuthnResponse,
-} from "../auth/models/response/two-factor-web-authn.response";
-import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
import { PaymentResponse } from "../billing/models/response/payment.response";
@@ -306,66 +285,6 @@ export abstract class ApiService {
abstract getSettingsDomains(): Promise;
abstract putSettingsDomains(request: UpdateDomainsRequest): Promise;
- abstract getTwoFactorProviders(): Promise>;
- abstract getTwoFactorOrganizationProviders(
- organizationId: string,
- ): Promise>;
- abstract getTwoFactorAuthenticator(
- request: SecretVerificationRequest,
- ): Promise;
- abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise;
- abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise;
- abstract getTwoFactorOrganizationDuo(
- organizationId: string,
- request: SecretVerificationRequest,
- ): Promise;
- abstract getTwoFactorYubiKey(
- request: SecretVerificationRequest,
- ): Promise;
- abstract getTwoFactorWebAuthn(
- request: SecretVerificationRequest,
- ): Promise;
- abstract getTwoFactorWebAuthnChallenge(
- request: SecretVerificationRequest,
- ): Promise;
- abstract getTwoFactorRecover(
- request: SecretVerificationRequest,
- ): Promise;
- abstract putTwoFactorAuthenticator(
- request: UpdateTwoFactorAuthenticatorRequest,
- ): Promise;
- abstract deleteTwoFactorAuthenticator(
- request: DisableTwoFactorAuthenticatorRequest,
- ): Promise;
- abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise;
- abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise;
- abstract putTwoFactorOrganizationDuo(
- organizationId: string,
- request: UpdateTwoFactorDuoRequest,
- ): Promise;
- abstract putTwoFactorYubiKey(
- request: UpdateTwoFactorYubikeyOtpRequest,
- ): Promise;
- abstract putTwoFactorWebAuthn(
- request: UpdateTwoFactorWebAuthnRequest,
- ): Promise;
- abstract deleteTwoFactorWebAuthn(
- request: UpdateTwoFactorWebAuthnDeleteRequest,
- ): Promise;
- abstract putTwoFactorDisable(
- request: TwoFactorProviderRequest,
- ): Promise;
- abstract putTwoFactorOrganizationDisable(
- organizationId: string,
- request: TwoFactorProviderRequest,
- ): Promise;
- abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise;
- abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise;
- abstract getDeviceVerificationSettings(): Promise;
- abstract putDeviceVerificationSettings(
- request: DeviceVerificationRequest,
- ): Promise;
-
abstract getCloudCommunicationsEnabled(): Promise;
abstract getOrganizationConnection(
id: string,
diff --git a/libs/common/src/auth/models/request/device-verification.request.ts b/libs/common/src/auth/models/request/device-verification.request.ts
deleted file mode 100644
index 5e119efa190..00000000000
--- a/libs/common/src/auth/models/request/device-verification.request.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export class DeviceVerificationRequest {
- unknownDeviceVerificationEnabled: boolean;
-
- constructor(unknownDeviceVerificationEnabled: boolean) {
- this.unknownDeviceVerificationEnabled = unknownDeviceVerificationEnabled;
- }
-}
diff --git a/libs/common/src/auth/models/response/device-verification.response.ts b/libs/common/src/auth/models/response/device-verification.response.ts
deleted file mode 100644
index d703605703f..00000000000
--- a/libs/common/src/auth/models/response/device-verification.response.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { BaseResponse } from "../../../models/response/base.response";
-
-export class DeviceVerificationResponse extends BaseResponse {
- isDeviceVerificationSectionEnabled: boolean;
- unknownDeviceVerificationEnabled: boolean;
-
- constructor(response: any) {
- super(response);
- this.isDeviceVerificationSectionEnabled = this.getResponseProperty(
- "IsDeviceVerificationSectionEnabled",
- );
- this.unknownDeviceVerificationEnabled = this.getResponseProperty(
- "UnknownDeviceVerificationEnabled",
- );
- }
-}
diff --git a/libs/common/src/auth/two-factor/default-two-factor-api.service.ts b/libs/common/src/auth/two-factor/default-two-factor-api.service.ts
new file mode 100644
index 00000000000..93f7b207922
--- /dev/null
+++ b/libs/common/src/auth/two-factor/default-two-factor-api.service.ts
@@ -0,0 +1,272 @@
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
+import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
+import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
+import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
+import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
+import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
+import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
+import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
+import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
+import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
+import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
+import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
+import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
+import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
+import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
+import {
+ TwoFactorWebAuthnResponse,
+ ChallengeResponse,
+} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
+import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
+import { ListResponse } from "@bitwarden/common/models/response/list.response";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
+
+import { TwoFactorApiService } from "./two-factor-api.service";
+
+export class DefaultTwoFactorApiService implements TwoFactorApiService {
+ constructor(private apiService: ApiService) {}
+
+ // Providers
+
+ async getTwoFactorProviders(): Promise> {
+ const response = await this.apiService.send("GET", "/two-factor", null, true, true);
+ return new ListResponse(response, TwoFactorProviderResponse);
+ }
+
+ async getTwoFactorOrganizationProviders(
+ organizationId: string,
+ ): Promise> {
+ const response = await this.apiService.send(
+ "GET",
+ `/organizations/${organizationId}/two-factor`,
+ null,
+ true,
+ true,
+ );
+ return new ListResponse(response, TwoFactorProviderResponse);
+ }
+
+ // Authenticator (TOTP)
+
+ async getTwoFactorAuthenticator(
+ request: SecretVerificationRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-authenticator",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorAuthenticatorResponse(response);
+ }
+
+ async putTwoFactorAuthenticator(
+ request: UpdateTwoFactorAuthenticatorRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "PUT",
+ "/two-factor/authenticator",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorAuthenticatorResponse(response);
+ }
+
+ async deleteTwoFactorAuthenticator(
+ request: DisableTwoFactorAuthenticatorRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "DELETE",
+ "/two-factor/authenticator",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorProviderResponse(response);
+ }
+
+ // Email
+
+ async getTwoFactorEmail(request: SecretVerificationRequest): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-email",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorEmailResponse(response);
+ }
+
+ async postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise {
+ return this.apiService.send("POST", "/two-factor/send-email", request, true, false);
+ }
+
+ async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise {
+ return this.apiService.send("POST", "/two-factor/send-email-login", request, false, false);
+ }
+
+ async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise {
+ const response = await this.apiService.send("PUT", "/two-factor/email", request, true, true);
+ return new TwoFactorEmailResponse(response);
+ }
+
+ // Duo
+
+ async getTwoFactorDuo(request: SecretVerificationRequest): Promise {
+ const response = await this.apiService.send("POST", "/two-factor/get-duo", request, true, true);
+ return new TwoFactorDuoResponse(response);
+ }
+
+ async getTwoFactorOrganizationDuo(
+ organizationId: string,
+ request: SecretVerificationRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ `/organizations/${organizationId}/two-factor/get-duo`,
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorDuoResponse(response);
+ }
+
+ async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise {
+ const response = await this.apiService.send("PUT", "/two-factor/duo", request, true, true);
+ return new TwoFactorDuoResponse(response);
+ }
+
+ async putTwoFactorOrganizationDuo(
+ organizationId: string,
+ request: UpdateTwoFactorDuoRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "PUT",
+ `/organizations/${organizationId}/two-factor/duo`,
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorDuoResponse(response);
+ }
+
+ // YubiKey
+
+ async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-yubikey",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorYubiKeyResponse(response);
+ }
+
+ async putTwoFactorYubiKey(
+ request: UpdateTwoFactorYubikeyOtpRequest,
+ ): Promise {
+ const response = await this.apiService.send("PUT", "/two-factor/yubikey", request, true, true);
+ return new TwoFactorYubiKeyResponse(response);
+ }
+
+ // WebAuthn
+
+ async getTwoFactorWebAuthn(
+ request: SecretVerificationRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-webauthn",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorWebAuthnResponse(response);
+ }
+
+ async getTwoFactorWebAuthnChallenge(
+ request: SecretVerificationRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-webauthn-challenge",
+ request,
+ true,
+ true,
+ );
+ return new ChallengeResponse(response);
+ }
+
+ async putTwoFactorWebAuthn(
+ request: UpdateTwoFactorWebAuthnRequest,
+ ): Promise {
+ const deviceResponse = request.deviceResponse.response as AuthenticatorAttestationResponse;
+ const body: any = Object.assign({}, request);
+
+ body.deviceResponse = {
+ id: request.deviceResponse.id,
+ rawId: btoa(request.deviceResponse.id),
+ type: request.deviceResponse.type,
+ extensions: request.deviceResponse.getClientExtensionResults(),
+ response: {
+ AttestationObject: Utils.fromBufferToB64(deviceResponse.attestationObject),
+ clientDataJson: Utils.fromBufferToB64(deviceResponse.clientDataJSON),
+ },
+ };
+
+ const response = await this.apiService.send("PUT", "/two-factor/webauthn", body, true, true);
+ return new TwoFactorWebAuthnResponse(response);
+ }
+
+ async deleteTwoFactorWebAuthn(
+ request: UpdateTwoFactorWebAuthnDeleteRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "DELETE",
+ "/two-factor/webauthn",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorWebAuthnResponse(response);
+ }
+
+ // Recovery Code
+
+ async getTwoFactorRecover(request: SecretVerificationRequest): Promise {
+ const response = await this.apiService.send(
+ "POST",
+ "/two-factor/get-recover",
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorRecoverResponse(response);
+ }
+
+ // Disable
+
+ async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise {
+ const response = await this.apiService.send("PUT", "/two-factor/disable", request, true, true);
+ return new TwoFactorProviderResponse(response);
+ }
+
+ async putTwoFactorOrganizationDisable(
+ organizationId: string,
+ request: TwoFactorProviderRequest,
+ ): Promise {
+ const response = await this.apiService.send(
+ "PUT",
+ `/organizations/${organizationId}/two-factor/disable`,
+ request,
+ true,
+ true,
+ );
+ return new TwoFactorProviderResponse(response);
+ }
+}
diff --git a/libs/common/src/auth/two-factor/index.ts b/libs/common/src/auth/two-factor/index.ts
new file mode 100644
index 00000000000..85e072403b7
--- /dev/null
+++ b/libs/common/src/auth/two-factor/index.ts
@@ -0,0 +1,2 @@
+export { TwoFactorApiService } from "./two-factor-api.service";
+export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts b/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
new file mode 100644
index 00000000000..c54790d9f76
--- /dev/null
+++ b/libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
@@ -0,0 +1,697 @@
+import { mock, MockProxy } from "jest-mock-extended";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
+import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
+import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
+import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
+import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
+import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
+import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
+import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
+import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
+import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
+import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
+import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
+import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
+import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
+import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
+import {
+ TwoFactorWebAuthnResponse,
+ ChallengeResponse,
+} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
+import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
+import { ListResponse } from "@bitwarden/common/models/response/list.response";
+
+import { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
+
+describe("TwoFactorApiService", () => {
+ let apiService: MockProxy;
+ let twoFactorApiService: DefaultTwoFactorApiService;
+
+ beforeEach(() => {
+ apiService = mock();
+ twoFactorApiService = new DefaultTwoFactorApiService(apiService);
+ });
+
+ describe("Two-Factor Providers", () => {
+ describe("getTwoFactorProviders", () => {
+ it("retrieves all enabled two-factor providers for the current user", async () => {
+ const mockResponse = {
+ data: [
+ { Type: 0, Enabled: true },
+ { Type: 1, Enabled: true },
+ ],
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorProviders();
+
+ expect(apiService.send).toHaveBeenCalledWith("GET", "/two-factor", null, true, true);
+ expect(result).toBeInstanceOf(ListResponse);
+ expect(result.data).toHaveLength(2);
+ for (let i = 0; i < result.data.length; i++) {
+ expect(result.data[i]).toBeInstanceOf(TwoFactorProviderResponse);
+ expect(result.data[i].type).toBe(i);
+ expect(result.data[i].enabled).toBe(true);
+ }
+ });
+ });
+
+ describe("getTwoFactorOrganizationProviders", () => {
+ it("retrieves all enabled two-factor providers for a specific organization", async () => {
+ const organizationId = "org-123";
+ const mockResponse = {
+ data: [{ Type: 6, Enabled: true }],
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorOrganizationProviders(organizationId);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "GET",
+ `/organizations/${organizationId}/two-factor`,
+ null,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(ListResponse);
+ expect(result.data[0]).toBeInstanceOf(TwoFactorProviderResponse);
+ expect(result.data[0].enabled).toBe(true);
+ expect(result.data[0].type).toBe(6); // Duo
+ });
+ });
+ });
+
+ describe("Authenticator (TOTP) APIs", () => {
+ describe("getTwoFactorAuthenticator", () => {
+ it("retrieves authenticator configuration with secret key after user verification", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: false,
+ Key: "MFRGGZDFMZTWQ2LK",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorAuthenticator(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-authenticator",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse);
+ expect(result.enabled).toBe(false);
+ });
+ });
+
+ describe("putTwoFactorAuthenticator", () => {
+ it("enables authenticator after validating the provided token", async () => {
+ const request = new UpdateTwoFactorAuthenticatorRequest();
+ request.token = "123456";
+ request.key = "MFRGGZDFMZTWQ2LK";
+ const mockResponse = {
+ Enabled: true,
+ Key: "MFRGGZDFMZTWQ2LK",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorAuthenticator(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ "/two-factor/authenticator",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorAuthenticatorResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.key).toBeDefined();
+ });
+ });
+
+ describe("deleteTwoFactorAuthenticator", () => {
+ it("disables authenticator two-factor authentication", async () => {
+ const request = new DisableTwoFactorAuthenticatorRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: false,
+ Type: 0,
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.deleteTwoFactorAuthenticator(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "DELETE",
+ "/two-factor/authenticator",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorProviderResponse);
+ expect(result.enabled).toBe(false);
+ expect(result.type).toBe(0); // Authenticator
+ });
+ });
+ });
+
+ describe("Email APIs", () => {
+ describe("getTwoFactorEmail", () => {
+ it("retrieves email two-factor configuration after user verification", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Email: "user@example.com",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorEmail(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-email",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorEmailResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.email).toBeDefined();
+ });
+ });
+
+ describe("postTwoFactorEmailSetup", () => {
+ it("sends verification code to email address during two-factor setup", async () => {
+ const request = new TwoFactorEmailRequest();
+ request.email = "user@example.com";
+ request.masterPasswordHash = "master-password-hash";
+
+ await twoFactorApiService.postTwoFactorEmailSetup(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/send-email",
+ request,
+ true,
+ false,
+ );
+ });
+ });
+
+ describe("postTwoFactorEmail", () => {
+ it("sends two-factor authentication code during login flow", async () => {
+ const request = new TwoFactorEmailRequest();
+ request.email = "user@example.com";
+ // Note: masterPasswordHash not required for login flow
+
+ await twoFactorApiService.postTwoFactorEmail(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/send-email-login",
+ request,
+ false,
+ false,
+ );
+ });
+ });
+
+ describe("putTwoFactorEmail", () => {
+ it("enables email two-factor after validating the verification code", async () => {
+ const request = new UpdateTwoFactorEmailRequest();
+ request.email = "user@example.com";
+ request.token = "verification-code";
+ const mockResponse = {
+ Enabled: true,
+ Email: "user@example.com",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorEmail(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ "/two-factor/email",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorEmailResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.email).toBeDefined();
+ });
+ });
+ });
+
+ describe("Duo APIs", () => {
+ describe("getTwoFactorDuo", () => {
+ it("retrieves Duo configuration for premium user after verification", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Host: "api-abc123.duosecurity.com",
+ ClientId: "DI9ABC1DEFGH2JKL",
+ ClientSecret: "client******",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorDuo(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-duo",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorDuoResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.host).toBeDefined();
+ expect(result.clientId).toBeDefined();
+ expect(result.clientSecret).toContain("******");
+ });
+ });
+
+ describe("getTwoFactorOrganizationDuo", () => {
+ it("retrieves Duo configuration for organization with admin permissions", async () => {
+ const organizationId = "org-123";
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Host: "api-xyz789.duosecurity.com",
+ ClientId: "DI4XYZ9MNOP3QRS",
+ ClientSecret: "orgcli******",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorOrganizationDuo(
+ organizationId,
+ request,
+ );
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ `/organizations/${organizationId}/two-factor/get-duo`,
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorDuoResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.host).toBeDefined();
+ expect(result.clientId).toBeDefined();
+ expect(result.clientSecret).toContain("******");
+ });
+ });
+
+ describe("putTwoFactorDuo", () => {
+ it("enables Duo two-factor for premium user with valid integration details", async () => {
+ const request = new UpdateTwoFactorDuoRequest();
+ request.host = "api-abc123.duosecurity.com";
+ request.clientId = "DI9ABC1DEFGH2JKL";
+ request.clientSecret = "client-secret-value-here";
+ const mockResponse = {
+ Enabled: true,
+ Host: "api-abc123.duosecurity.com",
+ ClientId: "DI9ABC1DEFGH2JKL",
+ ClientSecret: "client******",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorDuo(request);
+
+ expect(apiService.send).toHaveBeenCalledWith("PUT", "/two-factor/duo", request, true, true);
+ expect(result).toBeInstanceOf(TwoFactorDuoResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.host).toBeDefined();
+ expect(result.clientId).toBeDefined();
+ expect(result.clientSecret).toContain("******");
+ });
+ });
+
+ describe("putTwoFactorOrganizationDuo", () => {
+ it("enables organization-level Duo with policy management permissions", async () => {
+ const organizationId = "org-123";
+ const request = new UpdateTwoFactorDuoRequest();
+ request.host = "api-xyz789.duosecurity.com";
+ request.clientId = "DI4XYZ9MNOP3QRS";
+ request.clientSecret = "orgcli-secret-value-here";
+ const mockResponse = {
+ Enabled: true,
+ Host: "api-xyz789.duosecurity.com",
+ ClientId: "DI4XYZ9MNOP3QRS",
+ ClientSecret: "orgcli******",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorOrganizationDuo(
+ organizationId,
+ request,
+ );
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ `/organizations/${organizationId}/two-factor/duo`,
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorDuoResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.host).toBeDefined();
+ expect(result.clientId).toBeDefined();
+ expect(result.clientSecret).toContain("******");
+ });
+ });
+ });
+
+ describe("YubiKey APIs", () => {
+ describe("getTwoFactorYubiKey", () => {
+ it("retrieves YubiKey configuration for premium user after verification", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Key1: "cccccccccccc",
+ Key2: "dddddddddddd",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorYubiKey(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-yubikey",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.key1).toBeDefined();
+ expect(result.key2).toBeDefined();
+ });
+ });
+
+ describe("putTwoFactorYubiKey", () => {
+ it("enables YubiKey two-factor for premium user after validating device OTPs", async () => {
+ const request = new UpdateTwoFactorYubikeyOtpRequest();
+ request.key1 = "ccccccccccccjkhbhbhrkcitringjkrjirfjuunlnlvcghnkrtgfj";
+ request.key2 = "ddddddddddddvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv";
+ const mockResponse = {
+ Enabled: true,
+ Key1: "cccccccccccc",
+ Key2: "dddddddddddd",
+ Nfc: false,
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorYubiKey(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ "/two-factor/yubikey",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorYubiKeyResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.key1).toBeDefined();
+ expect(result.key2).toBeDefined();
+ });
+ });
+ });
+
+ describe("WebAuthn APIs", () => {
+ describe("getTwoFactorWebAuthn", () => {
+ it("retrieves list of registered WebAuthn credentials after verification", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Keys: [
+ { Name: "YubiKey 5", Id: 1, Migrated: false },
+ { Name: "Security Key", Id: 2, Migrated: true },
+ ],
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorWebAuthn(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-webauthn",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.keys).toHaveLength(2);
+ result.keys.forEach((key) => {
+ expect(key).toHaveProperty("name");
+ expect(key).toHaveProperty("id");
+ expect(key).toHaveProperty("migrated");
+ });
+ });
+ });
+
+ describe("getTwoFactorWebAuthnChallenge", () => {
+ it("obtains cryptographic challenge for WebAuthn credential registration", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ challenge: "Y2hhbGxlbmdlLXN0cmluZw",
+ rp: { name: "Bitwarden" },
+ user: {
+ id: "dXNlci1pZA",
+ name: "user@example.com",
+ displayName: "User",
+ },
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
+ excludeCredentials: [] as PublicKeyCredentialDescriptor[],
+ timeout: 60000,
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-webauthn-challenge",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(ChallengeResponse);
+ expect(result.challenge).toBeDefined();
+ expect(result.rp).toHaveProperty("name", "Bitwarden");
+ expect(result.user).toHaveProperty("id");
+ expect(result.user).toHaveProperty("name");
+ expect(result.user).toHaveProperty("displayName", "User");
+ expect(result.pubKeyCredParams).toHaveLength(1);
+ expect(Number(result.timeout)).toBeTruthy();
+ });
+ });
+
+ describe("putTwoFactorWebAuthn", () => {
+ it("registers new WebAuthn credential by serializing browser credential to JSON", async () => {
+ const mockAttestationResponse: Partial = {
+ clientDataJSON: new Uint8Array([1, 2, 3]).buffer,
+ attestationObject: new Uint8Array([4, 5, 6]).buffer,
+ };
+
+ const mockCredential: Partial = {
+ id: "credential-id",
+ type: "public-key",
+ response: mockAttestationResponse as AuthenticatorAttestationResponse,
+ getClientExtensionResults: jest.fn().mockReturnValue({}),
+ };
+
+ const request = new UpdateTwoFactorWebAuthnRequest();
+ request.deviceResponse = mockCredential as PublicKeyCredential;
+ request.name = "My Security Key";
+
+ const mockResponse = {
+ Enabled: true,
+ Keys: [{ Name: "My Security Key", Id: 1, Migrated: false }],
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorWebAuthn(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ "/two-factor/webauthn",
+ expect.objectContaining({
+ name: "My Security Key",
+ deviceResponse: expect.objectContaining({
+ id: "credential-id",
+ rawId: expect.any(String), // base64 encoded
+ type: "public-key",
+ extensions: {},
+ response: expect.objectContaining({
+ AttestationObject: expect.any(String), // base64 encoded
+ clientDataJson: expect.any(String), // base64 encoded
+ }),
+ }),
+ }),
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
+ expect(result.enabled).toBe(true);
+ expect(result.keys).toHaveLength(1);
+ expect(result.keys[0].name).toBeDefined();
+ expect(result.keys[0].id).toBeDefined();
+ expect(result.keys[0].migrated).toBeDefined();
+ });
+
+ it("preserves original request object without mutation during serialization", async () => {
+ const mockAttestationResponse: Partial = {
+ clientDataJSON: new Uint8Array([1, 2, 3]).buffer,
+ attestationObject: new Uint8Array([4, 5, 6]).buffer,
+ };
+
+ const mockCredential: Partial = {
+ id: "credential-id",
+ type: "public-key",
+ response: mockAttestationResponse as AuthenticatorAttestationResponse,
+ getClientExtensionResults: jest.fn().mockReturnValue({}),
+ };
+
+ const request = new UpdateTwoFactorWebAuthnRequest();
+ request.deviceResponse = mockCredential as PublicKeyCredential;
+ request.name = "My Security Key";
+
+ const originalDeviceResponse = request.deviceResponse;
+ apiService.send.mockResolvedValue({ enabled: true, keys: [] });
+
+ await twoFactorApiService.putTwoFactorWebAuthn(request);
+
+ // Do not mutate the original request object
+ expect(request.deviceResponse).toBe(originalDeviceResponse);
+ expect(request.deviceResponse.response).toBe(mockAttestationResponse);
+ });
+ });
+
+ describe("deleteTwoFactorWebAuthn", () => {
+ it("removes specific WebAuthn credential while preserving other registered keys", async () => {
+ const request = new UpdateTwoFactorWebAuthnDeleteRequest();
+ request.id = 1;
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: true,
+ Keys: [{ Name: "Security Key", Id: 2, Migrated: true }], // Key with id:1 removed
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.deleteTwoFactorWebAuthn(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "DELETE",
+ "/two-factor/webauthn",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorWebAuthnResponse);
+ expect(result.keys).toHaveLength(1);
+ expect(result.keys[0].id).toBe(2);
+ });
+ });
+ });
+
+ describe("Recovery Code APIs", () => {
+ describe("getTwoFactorRecover", () => {
+ it("retrieves recovery code for regaining access when two-factor is unavailable", async () => {
+ const request = new SecretVerificationRequest();
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Code: "ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ12",
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.getTwoFactorRecover(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "POST",
+ "/two-factor/get-recover",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorRecoverResponse);
+ expect(result.code).toBeDefined();
+ expect(result.code).toMatch(/^[A-Z0-9-]+$/);
+ });
+ });
+ });
+
+ describe("Disable APIs", () => {
+ describe("putTwoFactorDisable", () => {
+ it("disables specified two-factor provider for current user", async () => {
+ const request = new TwoFactorProviderRequest();
+ request.type = 0; // Authenticator
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: false,
+ Type: 0,
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorDisable(request);
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ "/two-factor/disable",
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorProviderResponse);
+ expect(result.enabled).toBe(false);
+ expect(result.type).toBe(0); // Authenticator
+ });
+ });
+
+ describe("putTwoFactorOrganizationDisable", () => {
+ it("disables two-factor provider for organization with policy management permissions", async () => {
+ const organizationId = "org-123";
+ const request = new TwoFactorProviderRequest();
+ request.type = 6; // Duo
+ request.masterPasswordHash = "master-password-hash";
+ const mockResponse = {
+ Enabled: false,
+ Type: 6,
+ };
+ apiService.send.mockResolvedValue(mockResponse);
+
+ const result = await twoFactorApiService.putTwoFactorOrganizationDisable(
+ organizationId,
+ request,
+ );
+
+ expect(apiService.send).toHaveBeenCalledWith(
+ "PUT",
+ `/organizations/${organizationId}/two-factor/disable`,
+ request,
+ true,
+ true,
+ );
+ expect(result).toBeInstanceOf(TwoFactorProviderResponse);
+ expect(result.enabled).toBe(false);
+ expect(result.type).toBe(6); // Duo
+ });
+ });
+ });
+});
diff --git a/libs/common/src/auth/two-factor/two-factor-api.service.ts b/libs/common/src/auth/two-factor/two-factor-api.service.ts
new file mode 100644
index 00000000000..278813c4c30
--- /dev/null
+++ b/libs/common/src/auth/two-factor/two-factor-api.service.ts
@@ -0,0 +1,292 @@
+import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
+import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
+import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
+import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
+import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
+import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
+import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
+import { UpdateTwoFactorWebAuthnDeleteRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn-delete.request";
+import { UpdateTwoFactorWebAuthnRequest } from "@bitwarden/common/auth/models/request/update-two-factor-web-authn.request";
+import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
+import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
+import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
+import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
+import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
+import { TwoFactorRecoverResponse } from "@bitwarden/common/auth/models/response/two-factor-recover.response";
+import {
+ ChallengeResponse,
+ TwoFactorWebAuthnResponse,
+} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
+import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
+import { ListResponse } from "@bitwarden/common/models/response/list.response";
+
+/**
+ * Service abstraction for two-factor authentication API operations.
+ * Provides methods for managing various two-factor authentication providers including
+ * authenticator apps (TOTP), email, Duo, YubiKey, WebAuthn (FIDO2), and recovery codes.
+ *
+ * All methods that retrieve sensitive configuration data require user verification via
+ * SecretVerificationRequest. Premium-tier providers (Duo, YubiKey) require an active
+ * premium subscription. Organization-level methods require appropriate administrative permissions.
+ */
+export abstract class TwoFactorApiService {
+ /**
+ * Gets a list of all enabled two-factor providers for the current user.
+ *
+ * @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
+ */
+ abstract getTwoFactorProviders(): Promise>;
+
+ /**
+ * Gets a list of all enabled two-factor providers for an organization.
+ * Requires organization administrator permissions.
+ *
+ * @param organizationId The ID of the organization.
+ * @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
+ */
+ abstract getTwoFactorOrganizationProviders(
+ organizationId: string,
+ ): Promise>;
+
+ /**
+ * Gets the authenticator (TOTP) two-factor configuration for the current user.
+ * Returns the shared secret key and user verification token needed for setup.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the authenticator configuration including the secret key.
+ */
+ abstract getTwoFactorAuthenticator(
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Gets the email two-factor configuration for the current user.
+ * Returns the configured email address and enabled status.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the email two-factor configuration.
+ */
+ abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise;
+
+ /**
+ * Gets the Duo two-factor configuration for the current user.
+ * Returns Duo integration configuration details.
+ * Requires user verification and an active premium subscription.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the Duo configuration.
+ */
+ abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise;
+
+ /**
+ * Gets the Duo two-factor configuration for an organization.
+ * Returns organization-level Duo integration configuration.
+ * Requires user verification and organization policy management permissions.
+ *
+ * @param organizationId The ID of the organization.
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the organization Duo configuration.
+ */
+ abstract getTwoFactorOrganizationDuo(
+ organizationId: string,
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Gets the YubiKey OTP two-factor configuration for the current user.
+ * Returns configured YubiKey device identifiers (multiple keys supported).
+ * Requires user verification and an active premium subscription.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the YubiKey configuration.
+ */
+ abstract getTwoFactorYubiKey(
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Gets the WebAuthn (FIDO2) two-factor configuration for the current user.
+ * Returns a list of registered WebAuthn credentials with their names and IDs.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the WebAuthn configuration including registered credentials.
+ */
+ abstract getTwoFactorWebAuthn(
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Gets a WebAuthn challenge for registering a new WebAuthn credential.
+ * This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge
+ * required for credential creation. The challenge is used by the browser's WebAuthn API.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the credential creation options containing the challenge.
+ */
+ abstract getTwoFactorWebAuthnChallenge(
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Gets the recovery code configuration for the current user.
+ * Returns the recovery code that can be used to regain access if other two-factor methods are unavailable.
+ * The recovery code should be stored securely by the user.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The secret verification request to authorize the operation.
+ * @returns A promise that resolves to the recovery code configuration.
+ */
+ abstract getTwoFactorRecover(
+ request: SecretVerificationRequest,
+ ): Promise;
+
+ /**
+ * Enables or updates the authenticator (TOTP) two-factor provider.
+ * Validates the provided token against the shared secret before enabling.
+ * The token must be generated by an authenticator app using the secret key.
+ *
+ * @param request The request containing the authenticator configuration and verification token.
+ * @returns A promise that resolves to the updated authenticator configuration.
+ */
+ abstract putTwoFactorAuthenticator(
+ request: UpdateTwoFactorAuthenticatorRequest,
+ ): Promise;
+
+ /**
+ * Disables the authenticator (TOTP) two-factor provider for the current user.
+ * Requires user verification token to confirm the operation.
+ *
+ * @param request The request containing verification credentials to disable the provider.
+ * @returns A promise that resolves to the updated provider status.
+ */
+ abstract deleteTwoFactorAuthenticator(
+ request: DisableTwoFactorAuthenticatorRequest,
+ ): Promise;
+
+ /**
+ * Enables or updates the email two-factor provider.
+ * Validates the email verification token sent via postTwoFactorEmailSetup before enabling.
+ * The token must match the code sent to the specified email address.
+ *
+ * @param request The request containing the email configuration and verification token.
+ * @returns A promise that resolves to the updated email two-factor configuration.
+ */
+ abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise;
+
+ /**
+ * Enables or updates the Duo two-factor provider for the current user.
+ * Validates the Duo configuration (client ID, client secret, and host) before enabling.
+ * Requires user verification and an active premium subscription.
+ *
+ * @param request The request containing the Duo integration configuration.
+ * @returns A promise that resolves to the updated Duo configuration.
+ */
+ abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise;
+
+ /**
+ * Enables or updates the Duo two-factor provider for an organization.
+ * Validates the Duo configuration (client ID, client secret, and host) before enabling.
+ * Requires user verification and organization policy management permissions.
+ *
+ * @param organizationId The ID of the organization.
+ * @param request The request containing the Duo integration configuration.
+ * @returns A promise that resolves to the updated organization Duo configuration.
+ */
+ abstract putTwoFactorOrganizationDuo(
+ organizationId: string,
+ request: UpdateTwoFactorDuoRequest,
+ ): Promise;
+
+ /**
+ * Enables or updates the YubiKey OTP two-factor provider.
+ * Validates each provided YubiKey by testing an OTP from the device.
+ * Supports up to 5 YubiKey devices. Empty key slots are allowed.
+ * Requires user verification and an active premium subscription.
+ * Includes a 2-second delay on validation failure to prevent timing attacks.
+ *
+ * @param request The request containing YubiKey device identifiers and test OTPs.
+ * @returns A promise that resolves to the updated YubiKey configuration.
+ */
+ abstract putTwoFactorYubiKey(
+ request: UpdateTwoFactorYubikeyOtpRequest,
+ ): Promise;
+
+ /**
+ * Registers a new WebAuthn (FIDO2) credential for two-factor authentication.
+ * Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow.
+ * The device response contains the signed challenge from the authenticator device.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The request containing the WebAuthn credential creation response from the browser.
+ * @returns A promise that resolves to the updated WebAuthn configuration with the new credential.
+ */
+ abstract putTwoFactorWebAuthn(
+ request: UpdateTwoFactorWebAuthnRequest,
+ ): Promise;
+
+ /**
+ * Removes a specific WebAuthn (FIDO2) credential from the user's account.
+ * The credential will no longer be usable for two-factor authentication.
+ * Other registered WebAuthn credentials remain active.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The request containing the credential ID to remove.
+ * @returns A promise that resolves to the updated WebAuthn configuration.
+ */
+ abstract deleteTwoFactorWebAuthn(
+ request: UpdateTwoFactorWebAuthnDeleteRequest,
+ ): Promise;
+
+ /**
+ * Disables a specific two-factor provider for the current user.
+ * The provider will no longer be required or usable for authentication.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The request specifying which provider type to disable.
+ * @returns A promise that resolves to the updated provider status.
+ */
+ abstract putTwoFactorDisable(
+ request: TwoFactorProviderRequest,
+ ): Promise;
+
+ /**
+ * Disables a specific two-factor provider for an organization.
+ * The provider will no longer be available for organization members.
+ * Requires user verification and organization policy management permissions.
+ *
+ * @param organizationId The ID of the organization.
+ * @param request The request specifying which provider type to disable.
+ * @returns A promise that resolves to the updated provider status.
+ */
+ abstract putTwoFactorOrganizationDisable(
+ organizationId: string,
+ request: TwoFactorProviderRequest,
+ ): Promise;
+
+ /**
+ * Initiates email two-factor setup by sending a verification code to the specified email address.
+ * This is the first step in enabling email two-factor authentication.
+ * The verification code must be provided to putTwoFactorEmail to complete setup.
+ * Only used during initial configuration, not during login flows.
+ * Requires user verification via master password or OTP.
+ *
+ * @param request The request containing the email address for two-factor setup.
+ * @returns A promise that resolves when the verification email has been sent.
+ */
+ abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise;
+
+ /**
+ * Sends a two-factor authentication code via email during the login flow.
+ * Supports multiple authentication contexts including standard login, SSO, and passwordless flows.
+ * This is used to deliver codes during authentication, not during initial setup.
+ * May be called without authentication for login scenarios.
+ *
+ * @param request The request to send the two-factor code, optionally including SSO or auth request tokens.
+ * @returns A promise that resolves when the authentication email has been sent.
+ */
+ abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise;
+}
diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts
index f000f35f126..3b4fef9c5c4 100644
--- a/libs/common/src/services/api.service.ts
+++ b/libs/common/src/services/api.service.ts
@@ -48,8 +48,6 @@ import {
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { AccountService } from "../auth/abstractions/account.service";
import { TokenService } from "../auth/abstractions/token.service";
-import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
-import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
import { EmailRequest } from "../auth/models/request/email.request";
import { DeviceRequest } from "../auth/models/request/identity-token/device.request";
@@ -61,34 +59,15 @@ import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
-import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
-import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
-import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
-import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
-import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
-import { UpdateTwoFactorWebAuthnDeleteRequest } from "../auth/models/request/update-two-factor-web-authn-delete.request";
-import { UpdateTwoFactorWebAuthnRequest } from "../auth/models/request/update-two-factor-web-authn.request";
-import { UpdateTwoFactorYubikeyOtpRequest } from "../auth/models/request/update-two-factor-yubikey-otp.request";
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
-import { DeviceVerificationResponse } from "../auth/models/response/device-verification.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
-import { TwoFactorAuthenticatorResponse } from "../auth/models/response/two-factor-authenticator.response";
-import { TwoFactorDuoResponse } from "../auth/models/response/two-factor-duo.response";
-import { TwoFactorEmailResponse } from "../auth/models/response/two-factor-email.response";
-import { TwoFactorProviderResponse } from "../auth/models/response/two-factor-provider.response";
-import { TwoFactorRecoverResponse } from "../auth/models/response/two-factor-recover.response";
-import {
- ChallengeResponse,
- TwoFactorWebAuthnResponse,
-} from "../auth/models/response/two-factor-web-authn.response";
-import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response";
import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request";
import { BillingHistoryResponse } from "../billing/models/response/billing-history.response";
import { PaymentResponse } from "../billing/models/response/payment.response";
@@ -809,205 +788,6 @@ export class ApiService implements ApiServiceAbstraction {
return new SyncResponse(r);
}
- // Two-factor APIs
-
- async getTwoFactorProviders(): Promise> {
- const r = await this.send("GET", "/two-factor", null, true, true);
- return new ListResponse(r, TwoFactorProviderResponse);
- }
-
- async getTwoFactorOrganizationProviders(
- organizationId: string,
- ): Promise> {
- const r = await this.send(
- "GET",
- "/organizations/" + organizationId + "/two-factor",
- null,
- true,
- true,
- );
- return new ListResponse(r, TwoFactorProviderResponse);
- }
-
- async getTwoFactorAuthenticator(
- request: SecretVerificationRequest,
- ): Promise {
- const r = await this.send("POST", "/two-factor/get-authenticator", request, true, true);
- return new TwoFactorAuthenticatorResponse(r);
- }
-
- async getTwoFactorEmail(request: SecretVerificationRequest): Promise {
- const r = await this.send("POST", "/two-factor/get-email", request, true, true);
- return new TwoFactorEmailResponse(r);
- }
-
- async getTwoFactorDuo(request: SecretVerificationRequest): Promise {
- const r = await this.send("POST", "/two-factor/get-duo", request, true, true);
- return new TwoFactorDuoResponse(r);
- }
-
- async getTwoFactorOrganizationDuo(
- organizationId: string,
- request: SecretVerificationRequest,
- ): Promise {
- const r = await this.send(
- "POST",
- "/organizations/" + organizationId + "/two-factor/get-duo",
- request,
- true,
- true,
- );
- return new TwoFactorDuoResponse(r);
- }
-
- async getTwoFactorYubiKey(request: SecretVerificationRequest): Promise {
- const r = await this.send("POST", "/two-factor/get-yubikey", request, true, true);
- return new TwoFactorYubiKeyResponse(r);
- }
-
- async getTwoFactorWebAuthn(
- request: SecretVerificationRequest,
- ): Promise {
- const r = await this.send("POST", "/two-factor/get-webauthn", request, true, true);
- return new TwoFactorWebAuthnResponse(r);
- }
-
- async getTwoFactorWebAuthnChallenge(
- request: SecretVerificationRequest,
- ): Promise {
- const r = await this.send("POST", "/two-factor/get-webauthn-challenge", request, true, true);
- return new ChallengeResponse(r);
- }
-
- async getTwoFactorRecover(request: SecretVerificationRequest): Promise {
- const r = await this.send("POST", "/two-factor/get-recover", request, true, true);
- return new TwoFactorRecoverResponse(r);
- }
-
- async putTwoFactorAuthenticator(
- request: UpdateTwoFactorAuthenticatorRequest,
- ): Promise {
- const r = await this.send("PUT", "/two-factor/authenticator", request, true, true);
- return new TwoFactorAuthenticatorResponse(r);
- }
-
- async deleteTwoFactorAuthenticator(
- request: DisableTwoFactorAuthenticatorRequest,
- ): Promise {
- const r = await this.send("DELETE", "/two-factor/authenticator", request, true, true);
- return new TwoFactorProviderResponse(r);
- }
-
- async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise {
- const r = await this.send("PUT", "/two-factor/email", request, true, true);
- return new TwoFactorEmailResponse(r);
- }
-
- async putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise {
- const r = await this.send("PUT", "/two-factor/duo", request, true, true);
- return new TwoFactorDuoResponse(r);
- }
-
- async putTwoFactorOrganizationDuo(
- organizationId: string,
- request: UpdateTwoFactorDuoRequest,
- ): Promise {
- const r = await this.send(
- "PUT",
- "/organizations/" + organizationId + "/two-factor/duo",
- request,
- true,
- true,
- );
- return new TwoFactorDuoResponse(r);
- }
-
- async putTwoFactorYubiKey(
- request: UpdateTwoFactorYubikeyOtpRequest,
- ): Promise {
- const r = await this.send("PUT", "/two-factor/yubikey", request, true, true);
- return new TwoFactorYubiKeyResponse(r);
- }
-
- async putTwoFactorWebAuthn(
- request: UpdateTwoFactorWebAuthnRequest,
- ): Promise {
- const response = request.deviceResponse.response as AuthenticatorAttestationResponse;
- const data: any = Object.assign({}, request);
-
- data.deviceResponse = {
- id: request.deviceResponse.id,
- rawId: btoa(request.deviceResponse.id),
- type: request.deviceResponse.type,
- extensions: request.deviceResponse.getClientExtensionResults(),
- response: {
- AttestationObject: Utils.fromBufferToB64(response.attestationObject),
- clientDataJson: Utils.fromBufferToB64(response.clientDataJSON),
- },
- };
-
- const r = await this.send("PUT", "/two-factor/webauthn", data, true, true);
- return new TwoFactorWebAuthnResponse(r);
- }
-
- async deleteTwoFactorWebAuthn(
- request: UpdateTwoFactorWebAuthnDeleteRequest,
- ): Promise {
- const r = await this.send("DELETE", "/two-factor/webauthn", request, true, true);
- return new TwoFactorWebAuthnResponse(r);
- }
-
- async putTwoFactorDisable(request: TwoFactorProviderRequest): Promise {
- const r = await this.send("PUT", "/two-factor/disable", request, true, true);
- return new TwoFactorProviderResponse(r);
- }
-
- async putTwoFactorOrganizationDisable(
- organizationId: string,
- request: TwoFactorProviderRequest,
- ): Promise {
- const r = await this.send(
- "PUT",
- "/organizations/" + organizationId + "/two-factor/disable",
- request,
- true,
- true,
- );
- return new TwoFactorProviderResponse(r);
- }
-
- postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise {
- return this.send("POST", "/two-factor/send-email", request, true, false);
- }
-
- postTwoFactorEmail(request: TwoFactorEmailRequest): Promise {
- return this.send("POST", "/two-factor/send-email-login", request, false, false);
- }
-
- async getDeviceVerificationSettings(): Promise {
- const r = await this.send(
- "GET",
- "/two-factor/get-device-verification-settings",
- null,
- true,
- true,
- );
- return new DeviceVerificationResponse(r);
- }
-
- async putDeviceVerificationSettings(
- request: DeviceVerificationRequest,
- ): Promise {
- const r = await this.send(
- "PUT",
- "/two-factor/device-verification-settings",
- request,
- true,
- true,
- );
- return new DeviceVerificationResponse(r);
- }
-
// Organization APIs
async getCloudCommunicationsEnabled(): Promise {
From d082d336e7b6bebd792035a470fd58dc51bfafe0 Mon Sep 17 00:00:00 2001
From: Dave <3836813+enmande@users.noreply.github.com>
Date: Mon, 13 Oct 2025 14:22:49 -0400
Subject: [PATCH 12/37] =?UTF-8?q?Revert=20"feat(two-factor-api-service)=20?=
=?UTF-8?q?[PM-26465]:=20(Refactor)=20Two-Factor=20API=20Se=E2=80=A6"=20(#?=
=?UTF-8?q?16856)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit 886003ba88a51b9672f478894de5a6b1da16b140.
---
apps/cli/src/auth/commands/login.command.ts | 6 +-
apps/cli/src/program.ts | 2 +-
.../service-container/service-container.ts | 4 -
.../settings/two-factor-setup.component.ts | 8 +-
.../account/change-email.component.spec.ts | 6 +-
.../account/change-email.component.ts | 4 +-
...account-verify-devices-dialog.component.ts | 6 +-
...wo-factor-setup-authenticator.component.ts | 10 +-
.../two-factor-setup-duo.component.ts | 13 +-
.../two-factor-setup-email.component.ts | 10 +-
.../two-factor-setup-method-base.component.ts | 15 +-
.../two-factor-setup-webauthn.component.ts | 12 +-
.../two-factor-setup-yubikey.component.ts | 8 +-
.../two-factor/two-factor-setup.component.ts | 6 +-
.../two-factor/two-factor-verify.component.ts | 18 +-
.../src/services/jslib-services.module.ts | 6 -
.../two-factor-auth-email.component.ts | 6 +-
libs/common/src/abstractions/api.service.ts | 81 ++
.../request/device-verification.request.ts | 7 +
.../response/device-verification.response.ts | 16 +
.../default-two-factor-api.service.ts | 272 -------
libs/common/src/auth/two-factor/index.ts | 2 -
.../two-factor/two-factor-api.service.spec.ts | 697 ------------------
.../auth/two-factor/two-factor-api.service.ts | 292 --------
libs/common/src/services/api.service.ts | 220 ++++++
25 files changed, 383 insertions(+), 1344 deletions(-)
create mode 100644 libs/common/src/auth/models/request/device-verification.request.ts
create mode 100644 libs/common/src/auth/models/response/device-verification.response.ts
delete mode 100644 libs/common/src/auth/two-factor/default-two-factor-api.service.ts
delete mode 100644 libs/common/src/auth/two-factor/index.ts
delete mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.spec.ts
delete mode 100644 libs/common/src/auth/two-factor/two-factor-api.service.ts
diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts
index e1a3d123441..7e5058dcff0 100644
--- a/apps/cli/src/auth/commands/login.command.ts
+++ b/apps/cli/src/auth/commands/login.command.ts
@@ -14,6 +14,7 @@ import {
SsoUrlService,
UserApiLoginCredentials,
} from "@bitwarden/auth/common";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
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";
@@ -28,7 +29,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
-import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
@@ -62,7 +62,7 @@ export class LoginCommand {
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected authService: AuthService,
- protected twoFactorApiService: TwoFactorApiService,
+ protected apiService: ApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
@@ -279,7 +279,7 @@ export class LoginCommand {
const emailReq = new TwoFactorEmailRequest();
emailReq.email = await this.loginStrategyService.getEmail();
emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
- await this.twoFactorApiService.postTwoFactorEmail(emailReq);
+ await this.apiService.postTwoFactorEmail(emailReq);
}
if (twoFactorToken == null) {
diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts
index e8c9bff746e..8f202bc0845 100644
--- a/apps/cli/src/program.ts
+++ b/apps/cli/src/program.ts
@@ -175,7 +175,7 @@ export class Program extends BaseProgram {
const command = new LoginCommand(
this.serviceContainer.loginStrategyService,
this.serviceContainer.authService,
- this.serviceContainer.twoFactorApiService,
+ this.serviceContainer.apiService,
this.serviceContainer.masterPasswordApiService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.environmentService,
diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts
index 7c4b708576b..d13d251bce0 100644
--- a/apps/cli/src/service-container/service-container.ts
+++ b/apps/cli/src/service-container/service-container.ts
@@ -49,7 +49,6 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
-import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -229,7 +228,6 @@ export class ServiceContainer {
tokenService: TokenService;
appIdService: AppIdService;
apiService: NodeApiService;
- twoFactorApiService: TwoFactorApiService;
hibpApiService: HibpApiService;
environmentService: EnvironmentService;
cipherService: CipherService;
@@ -530,8 +528,6 @@ export class ServiceContainer {
this.configApiService = new ConfigApiService(this.apiService);
- this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService);
-
this.authService = new AuthService(
this.accountService,
this.messagingService,
diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
index 3151e0a702f..020a16dd932 100644
--- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
+++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts
@@ -5,6 +5,7 @@ import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs";
import { first, tap } from "rxjs/operators";
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
@@ -14,7 +15,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
-import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -35,7 +35,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
tabbedHeader = false;
constructor(
dialogService: DialogService,
- twoFactorApiService: TwoFactorApiService,
+ apiService: ApiService,
messagingService: MessagingService,
policyService: PolicyService,
private route: ActivatedRoute,
@@ -47,7 +47,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
) {
super(
dialogService,
- twoFactorApiService,
+ apiService,
messagingService,
policyService,
billingAccountProfileStateService,
@@ -116,7 +116,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
}
protected getTwoFactorProviders() {
- return this.twoFactorApiService.getTwoFactorOrganizationProviders(this.organizationId);
+ return this.apiService.getTwoFactorOrganizationProviders(this.organizationId);
}
protected filterProvider(type: TwoFactorProviderType): boolean {
diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
index 934de0f6453..bd0d9df9f06 100644
--- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
+++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts
@@ -7,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
-import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -23,14 +22,12 @@ describe("ChangeEmailComponent", () => {
let fixture: ComponentFixture;
let apiService: MockProxy;
- let twoFactorApiService: MockProxy;
let accountService: FakeAccountService;
let keyService: MockProxy;
let kdfConfigService: MockProxy;
beforeEach(async () => {
apiService = mock();
- twoFactorApiService = mock();
keyService = mock