1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 18:13:29 +00:00

Merge branch 'main' of github.com:bitwarden/clients into vault/pm-24978/corrupt-attachments

This commit is contained in:
Nick Krantz
2025-12-10 09:46:33 -06:00
612 changed files with 21923 additions and 5994 deletions

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -19,6 +19,7 @@ import { ClientType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
@@ -49,6 +50,7 @@ export type State = "assert" | "assertFailed";
})
export class LoginViaWebAuthnComponent implements OnInit {
protected currentState: State = "assert";
private shouldAutoClosePopout = false;
protected readonly Icons = {
TwoFactorAuthSecurityKeyIcon,
@@ -70,6 +72,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
constructor(
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
private router: Router,
private route: ActivatedRoute,
private logService: LogService,
private validationService: ValidationService,
private i18nService: I18nService,
@@ -77,9 +80,14 @@ export class LoginViaWebAuthnComponent implements OnInit {
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private messagingService: MessagingService,
) {}
ngOnInit(): void {
// Check if we should auto-close the popout after successful authentication
this.shouldAutoClosePopout =
this.route.snapshot.queryParamMap.get("autoClosePopout") === "true";
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.authenticate();
@@ -120,7 +128,18 @@ export class LoginViaWebAuthnComponent implements OnInit {
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
if (userKey) {
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, null);
}
// If autoClosePopout is enabled and we're in a browser extension,
// re-open the regular popup and close this popout window
if (
this.shouldAutoClosePopout &&
this.platformUtilsService.getClientType() === ClientType.Browser
) {
this.messagingService.send("openPopup");
window.close();
return;
}
await this.router.navigate([this.successRoute]);

View File

@@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
type="button"
*appNotPremium
bitBadge
variant="success"
[variant]="'primary'"
class="!tw-text-primary-600 !tw-border-primary-600"
(click)="promptForPremium($event)"
>
{{ "premium" | i18n }}
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
</button>
`,
imports: [BadgeModule, JslibModule],

View File

@@ -29,7 +29,7 @@ export default {
provide: I18nService,
useFactory: () => {
return new I18nMockService({
premium: "Premium",
upgrade: "Upgrade",
});
},
},

View File

@@ -20,33 +20,35 @@
<div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
>
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
<div class="tw-flex tw-items-center tw-justify-between">
<h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }}
</h3>
</div>
<!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6">
<div class="tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }}
</p>
</div>
<!-- Price Section -->
<div class="tw-mb-6">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
@if (cardDetails.price) {
<div class="tw-mt-5">
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
cardDetails.price.amount | currency: "$"
}}</span>
<span bitTypography="helper" class="tw-text-muted">
/ {{ cardDetails.price.cadence | i18n }}
</span>
</div>
</div>
</div>
}
<!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12">
<div class="tw-my-5 tw-h-12">
<button
bitButton
[buttonType]="cardDetails.button.type"

View File

@@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
});
});
});
describe("self-hosted environment", () => {
it("should handle null price data for self-hosted environment", async () => {
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: undefined as any, // self-host will have these prices empty
annualPricePerAdditionalStorageGB: undefined as any,
providedStorageGB: undefined as any,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
};
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([selfHostedPremiumTier]),
);
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const selfHostedComponent = selfHostedFixture.componentInstance;
selfHostedFixture.detectChanges();
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.price).toBeUndefined();
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
});
});
});

View File

@@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
},
};
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
{ key: "emergencyAccess", value: "Emergency access" },
{ key: "breachMonitoring", value: "Breach monitoring" },
{ key: "andMoreFeatures", value: "And more!" },
],
},
};
export default {
title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent,
@@ -86,11 +103,11 @@ export default {
t: (key: string) => {
switch (key) {
case "upgradeNow":
return "Upgrade Now";
return "Upgrade now";
case "month":
return "month";
case "upgradeToPremium":
return "Upgrade To Premium";
return "Upgrade to Premium";
default:
return key;
}
@@ -116,3 +133,18 @@ export default {
type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {};
export const NoPricingData: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
},
},
],
}),
],
};

View File

@@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonModule,
ButtonType,
CenterPositionStrategy,
DialogModule,
DialogRef,
@@ -27,14 +26,6 @@ import {
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({
selector: "billing-premium-upgrade-dialog",
standalone: true,
@@ -51,9 +42,8 @@ type CardDetails = {
templateUrl: "./premium-upgrade-dialog.component.html",
})
export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
.getPersonalSubscriptionPricingTiers$()
.pipe(
protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => {
@@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
this.dialogRef.close();
}
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
private mapPremiumTierToCardDetails(
tier: PersonalSubscriptionPricingTier,
): SubscriptionPricingCardDetails {
return {
title: tier.name,
tagline: tier.description,
price: {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
},
price: tier.passwordManager.annualPrice
? {
amount: tier.passwordManager.annualPrice / 12,
cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: {
text: this.i18nService.t("upgradeNow"),
type: "primary",

View File

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

View File

@@ -0,0 +1,9 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class EncryptedMigrationsSchedulerService {
/**
* Runs migrations for a user if needed, handling both interactive and non-interactive cases
* @param userId The user ID to run migrations for
*/
abstract runMigrationsIfNeeded(userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,270 @@
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import {
DefaultEncryptedMigrationsSchedulerService,
ENCRYPTED_MIGRATION_DISMISSED,
} from "./encrypted-migrations-scheduler.service";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: {
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
};
describe("DefaultEncryptedMigrationsSchedulerService", () => {
let service: DefaultEncryptedMigrationsSchedulerService;
const mockAccountService = new FakeAccountService(accounts);
const mockAuthService = mock<AuthService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockStateProvider = mock<StateProvider>();
const mockSyncService = mock<SyncService>();
const mockDialogService = mock<DialogService>();
const mockToastService = mock<ToastService>();
const mockI18nService = mock<I18nService>();
const mockLogService = mock<LogService>();
const mockRouter = mock<Router>();
const mockUserId = "test-user-id" as UserId;
const mockMasterPassword = "test-master-password";
const createMockUserState = <T>(value: T): jest.Mocked<SingleUserState<T>> =>
({
state$: of(value),
userId: mockUserId,
update: jest.fn(),
combinedState$: of([mockUserId, value]),
}) as any;
beforeEach(() => {
const mockDialogRef = {
closed: of(mockMasterPassword),
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
mockI18nService.t.mockReturnValue("translated_migrationsFailed");
(mockRouter as any)["events"] = of({ url: "/vault" }) as any;
service = new DefaultEncryptedMigrationsSchedulerService(
mockSyncService,
mockAccountService,
mockStateProvider,
mockEncryptedMigrator,
mockAuthService,
mockLogService,
mockDialogService,
mockToastService,
mockI18nService,
mockRouter,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("runMigrationsIfNeeded", () => {
it("should return early if user is not unlocked", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled();
expect(mockLogService.info).not.toHaveBeenCalled();
});
it("should log and return when no migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`,
);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
});
it("should run migrations without interaction when master password is not required", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
});
it("should run migrations with interaction when migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("runMigrationsWithoutInteraction", () => {
it("should run migrations without master password", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("should handle errors during migration without interaction", async () => {
const mockError = new Error("Migration failed");
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
mockError,
);
});
});
describe("runMigrationsWithInteraction", () => {
beforeEach(() => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
});
it("should skip if migration was dismissed recently", async () => {
const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
const mockUserState = createMockUserState(recentDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(mockLogService.info).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled();
});
it("should prompt for migration if dismissed date is older than 24 hours", async () => {
const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const mockUserState = createMockUserState(oldDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should prompt for migration if no dismiss date exists", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should set dismiss date when empty password is provided", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockDialogRef = {
closed: of(""), // Empty password
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
ENCRYPTED_MIGRATION_DISMISSED,
expect.any(Date),
mockUserId,
);
});
it("should handle errors during migration prompt and show toast", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockError = new Error("Migration failed");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration prompt",
mockError,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "translated_migrationsFailed",
});
});
});
});

View File

@@ -0,0 +1,188 @@
import { NavigationEnd, Router } from "@angular/router";
import {
combineLatest,
switchMap,
of,
firstValueFrom,
filter,
concatMap,
Observable,
map,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
UserKeyDefinition,
ENCRYPTED_MIGRATION_DISK,
StateProvider,
} from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
ENCRYPTED_MIGRATION_DISK,
"encryptedMigrationDismissed",
{
deserializer: (obj: string) => (obj != null ? new Date(obj) : null),
clearOn: [],
},
);
const DISMISS_TIME_HOURS = 24;
const VAULT_ROUTE = "/vault";
/**
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
*/
export class DefaultEncryptedMigrationsSchedulerService
implements EncryptedMigrationsSchedulerService
{
isMigrating = false;
url$: Observable<string>;
constructor(
private syncService: SyncService,
private accountService: AccountService,
private stateProvider: StateProvider,
private encryptedMigrator: EncryptedMigrator,
private authService: AuthService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
private router: Router,
) {
this.url$ = this.router.events.pipe(
filter((event: any) => event instanceof NavigationEnd),
map((event: NavigationEnd) => event.url),
);
// For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration
this.accountService.accounts$
.pipe(
switchMap((accounts) => {
const userIds = Object.keys(accounts) as UserId[];
if (userIds.length === 0) {
return of([]);
}
return combineLatest(
userIds.map((userId) =>
combineLatest([
this.authService.authStatusFor$(userId),
this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)),
this.url$,
]).pipe(
filter(
([authStatus, _date, url]) =>
authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE,
),
concatMap(() => this.runMigrationsIfNeeded(userId)),
),
),
);
}),
)
.subscribe();
}
async runMigrationsIfNeeded(userId: UserId): Promise<void> {
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) {
this.logService.info(
`[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`,
);
return;
}
this.isMigrating = true;
switch (await this.encryptedMigrator.needsMigrations(userId)) {
case "noMigrationNeeded":
this.logService.info(
`[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`,
);
break;
case "needsMigrationWithMasterPassword":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can run migrations with the master password
await this.runMigrationsWithInteraction(userId);
break;
case "needsMigration":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can prompt for the master password
await this.runMigrationsWithoutInteraction(userId);
break;
}
this.isMigrating = false;
}
private async runMigrationsWithoutInteraction(userId: UserId): Promise<void> {
try {
await this.encryptedMigrator.runMigrations(userId, null);
} catch (error) {
this.logService.error(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
error,
);
}
}
private async runMigrationsWithInteraction(userId: UserId): Promise<void> {
// A dialog can be dismissed for a certain amount of time
const dismissedDate = await firstValueFrom(
this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$,
);
if (dismissedDate != null) {
const now = new Date();
const timeDiff = now.getTime() - (dismissedDate as Date).getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60);
if (hoursDiff < DISMISS_TIME_HOURS) {
this.logService.info(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
return;
}
}
try {
const dialog = PromptMigrationPasswordComponent.open(this.dialogService);
const masterPassword = await firstValueFrom(dialog.closed);
if (Utils.isNullOrWhitespace(masterPassword)) {
await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId);
} else {
await this.encryptedMigrator.runMigrations(
userId,
masterPassword === undefined ? null : masterPassword,
);
}
} catch (error) {
this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error);
// If migrations failed when the user actively was prompted, show a toast
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("migrationsFailed"),
});
}
}
}

View File

@@ -0,0 +1,55 @@
<form [bitSubmit]="submit" [formGroup]="migrationPasswordForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
{{ "updateEncryptionSettingsTitle" | i18n }}
</div>
<div bitDialogContent>
<p>
{{ "updateEncryptionSettingsDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/kdf-algorithms/"
target="_blank"
rel="noreferrer"
aria-label="external link"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</p>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<bit-hint>{{ "confirmIdentityToContinue" | i18n }}</bit-hint>
<input
class="tw-font-mono"
bitInput
type="password"
formControlName="masterPassword"
[attr.title]="'masterPass' | i18n"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[attr.title]="'toggleVisibility' | i18n"
[attr.aria-label]="'toggleVisibility' | i18n"
></button>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="migrationPasswordForm.invalid"
>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "later" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,85 @@
import { CommonModule } from "@angular/common";
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { filter, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import {
LinkModule,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogRef,
DialogService,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
/**
* This is a generic prompt to run encryption migrations that require the master password.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "prompt-migration-password.component.html",
imports: [
DialogModule,
LinkModule,
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class PromptMigrationPasswordComponent {
private dialogRef = inject(DialogRef<string>);
private formBuilder = inject(FormBuilder);
private uvService = inject(UserVerificationService);
private accountService = inject(AccountService);
migrationPasswordForm = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
});
static open(dialogService: DialogService) {
return dialogService.open<string>(PromptMigrationPasswordComponent);
}
submit = async () => {
const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword;
if (!masterPasswordControl.value || masterPasswordControl.invalid) {
return;
}
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account != null),
map((account) => {
return {
userId: account!.id,
email: account!.email,
};
}),
),
);
if (
!(await this.uvService.verifyUserByMasterPassword(
{ type: VerificationType.MasterPassword, secret: masterPasswordControl.value },
userId,
email,
))
) {
return;
}
// Return the master password to the caller
this.dialogRef.close(masterPasswordControl.value);
};
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -177,10 +178,12 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
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";
@@ -204,6 +207,7 @@ import {
SendPasswordService,
DefaultSendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@@ -328,6 +332,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService,
} from "@bitwarden/components";
import {
@@ -396,6 +401,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -516,6 +523,23 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction,
],
}),
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
}),
safeProvider({
provide: EncryptedMigrator,
useClass: DefaultEncryptedMigrator,
deps: [
KdfConfigService,
ChangeKdfService,
LogService,
ConfigService,
MasterPasswordServiceAbstraction,
SyncService,
],
}),
safeProvider({
provide: LoginStrategyServiceAbstraction,
useClass: LoginStrategyService,
@@ -889,6 +913,7 @@ const safeProviders: SafeProvider[] = [
StateProvider,
LogService,
DEFAULT_VAULT_TIMEOUT,
SessionTimeoutTypeService,
],
}),
safeProvider({
@@ -1473,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
deps: [
BillingApiServiceAbstraction,
ConfigService,
I18nServiceAbstraction,
LogService,
EnvironmentService,
],
}),
safeProvider({
provide: OrganizationManagementPreferencesService,
@@ -1665,6 +1696,7 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction,
SyncService,
UserAsymmetricKeysRegenerationService,
EncryptedMigrator,
LogService,
],
}),
@@ -1735,6 +1767,28 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction,
],
}),
safeProvider({
provide: EncryptedMigrationsSchedulerService,
useClass: DefaultEncryptedMigrationsSchedulerService,
deps: [
SyncService,
AccountService,
StateProvider,
EncryptedMigrator,
AuthServiceAbstraction,
LogService,
DialogService,
ToastService,
I18nServiceAbstraction,
Router,
],
}),
safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
deps: [EncryptedMigrationsSchedulerService],
multi: true,
}),
safeProvider({
provide: LockService,
useClass: DefaultLockService,

View File

@@ -1,9 +1,5 @@
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
<ng-container *ngIf="data$ | async as data">
@if (data.imageEnabled && data.image) {
<img
@@ -16,7 +12,7 @@
'tw-invisible tw-absolute': !imageLoaded(),
'tw-size-6': !coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/>
@@ -28,7 +24,7 @@
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
[ngStyle]="iconStyle()"
>
<i
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
@@ -36,6 +32,7 @@
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
fontSize: size() ? size() + 'px' : null,
}"
></i>
</div>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
combineLatest,
@@ -32,8 +32,32 @@ export class IconComponent {
*/
readonly coloredIcon = input<boolean>(false);
/**
* Optional custom size for the icon in pixels.
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
*/
readonly size = input<number>();
readonly imageLoaded = signal(false);
/**
* Computed style object for icon dimensions.
* Centralizes the sizing logic to avoid repetition in the template.
*/
protected readonly iconStyle = computed(() => {
if (this.coloredIcon()) {
return { width: "36px", height: "36px" };
}
const size = this.size();
if (size) {
return { width: size + "px", height: size + "px" };
}
return {};
});
protected data$: Observable<CipherIconDetails>;
constructor(

View File

@@ -89,7 +89,7 @@ export class VaultFilterComponent implements OnInit {
this.collections = await this.initCollections();
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$(),
this.cipherArchiveService.hasArchiveFlagEnabled$,
);
this.isLoaded = true;

View File

@@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
private async handleSuccessfulLoginNavigation(userId: UserId) {
await this.loginSuccessHandlerService.run(userId);
await this.loginSuccessHandlerService.run(userId, null);
await this.router.navigate(["vault"]);
}
}

View File

@@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy {
}
// User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Determine where to send the user next
// The AuthGuard will handle routing to change-password based on state

View File

@@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// TODO: PM-22663 use the new service to handle routing.
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
return;
}
await this.loginSuccessHandlerService.run(authenticationResult.userId);
await this.loginSuccessHandlerService.run(
authenticationResult.userId,
authenticationResult.masterPassword ?? null,
);
if (this.premiumInterest) {
await this.premiumInterestStateService.setPremiumInterest(

View File

@@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
// Everything after the 2FA check is considered a successful login
// Just have to figure out where to send the user
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, null);
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
// - TDE login decryption options component

View File

@@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
// User is fully logged in so handle any post login logic before executing navigation
await this.loginSuccessHandlerService.run(authResult.userId);
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component

View File

@@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
* Runs any service calls required after a successful login.
* Service calls that should be included in this method are only those required to be awaited after successful login.
* @param userId The user id.
* @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods.
*/
abstract run(userId: UserId): Promise<void>;
abstract run(userId: UserId, masterPassword: string | null): Promise<void>;
}

View File

@@ -259,7 +259,7 @@ describe("LoginStrategy", () => {
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
UserDecryptionOptions.fromResponse(idTokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(
new MasterPasswordUnlockData(
@@ -308,6 +308,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = true;
expected.twoFactorProviders = null;
@@ -323,6 +324,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId;
expected.resetMasterPassword = false;
expected.twoFactorProviders = null;

View File

@@ -108,6 +108,8 @@ export abstract class LoginStrategy {
data.tokenRequest.setTwoFactor(twoFactor);
this.cache.next(data);
const [authResult] = await this.startLogIn();
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return authResult;
}
@@ -197,7 +199,7 @@ export abstract class LoginStrategy {
// as the user decryption options help determine the available timeout actions.
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
UserDecryptionOptions.fromResponse(tokenResponse),
UserDecryptionOptions.fromIdentityTokenResponse(tokenResponse),
);
if (tokenResponse.userDecryptionOptions?.masterPasswordUnlock != null) {
@@ -264,6 +266,9 @@ export abstract class LoginStrategy {
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
this.messagingService.send("loggedIn");
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
// TODO: https://bitwarden.atlassian.net/browse/PM-27573
result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return result;
}

View File

@@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
localMasterKeyHash: string;
/** The user's master key */
masterKey: MasterKey;
/** The user's master password */
masterPassword: string;
/**
* Tracks if the user needs to update their password due to
* a password that does not meet an organization's master password policy.
@@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
masterPassword,
email,
);
data.masterPassword = masterPassword;
data.userEnteredEmail = email;
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
@@ -251,6 +254,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
this.cache.next(data);
const [authResult] = await this.startLogIn();
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
return authResult;
}

View File

@@ -503,67 +503,6 @@ describe("SsoLoginStrategy", () => {
HasMasterPassword: false,
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
});
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl, userId);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = undefined;
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
{
kdfConfig: new Argon2KdfConfig(10, 64, 4),
keyConnectorUrl: keyConnectorUrl,
organizationId: ssoOrgId,
},
userId,
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray,
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLoginStrategy.logIn(credentials);
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
});
});
describe("Key Connector Pre-TDE", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse.userDecryptionOptions = null;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {

View File

@@ -157,22 +157,12 @@ export class SsoLoginStrategy extends LoginStrategy {
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
// and the user must not have a master password.
return userHasKeyConnectorUrl && !userHasMasterPassword;
} else {
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
// In this case, we can determine if the user has a master password and has a Key Connector URL by
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
// and will not pass back the URL in the response if the user has a master password.
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
return tokenResponse.keyConnectorUrl != null;
}
}
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
return (
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
);
return userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
}
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)

View File

@@ -112,10 +112,11 @@ export class UserDecryptionOptions {
* @throws If the response is nullish, this method will throw an error. User decryption options
* are required for client initialization.
*/
// TODO: Change response type to `UserDecryptionOptionsResponse` after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
static fromResponse(response: IdentityTokenResponse): UserDecryptionOptions {
static fromIdentityTokenResponse(response: IdentityTokenResponse): UserDecryptionOptions {
if (response == null) {
throw new Error("User Decryption Options are required for client initialization.");
throw new Error(
"User Decryption Options are required for client initialization. Response is nullish.",
);
}
const decryptionOptions = new UserDecryptionOptions();
@@ -134,17 +135,9 @@ export class UserDecryptionOptions {
responseOptions.keyConnectorOption,
);
} else {
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
// we must base our decryption options on the presence of the keyConnectorUrl.
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const usingKeyConnector = response.keyConnectorUrl != null;
decryptionOptions.hasMasterPassword = !usingKeyConnector;
if (usingKeyConnector) {
decryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption();
decryptionOptions.keyConnectorOption.keyConnectorUrl = response.keyConnectorUrl;
}
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
);
}
return decryptionOptions;
}

View File

@@ -10,6 +10,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
import { UserDecryptionOptionsResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -496,6 +497,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
apiService.postPrelogin.mockResolvedValue(
@@ -563,6 +565,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);
@@ -692,6 +695,7 @@ describe("LoginStrategyService", () => {
refresh_token: "REFRESH_TOKEN",
scope: "api offline_access",
token_type: "Bearer",
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
}),
);

View File

@@ -1,6 +1,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
@@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let syncService: MockProxy<SyncService>;
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
let encryptedMigrator: MockProxy<EncryptedMigrator>;
let logService: MockProxy<LogService>;
const userId = "USER_ID" as UserId;
@@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService = mock<SsoLoginServiceAbstraction>();
syncService = mock<SyncService>();
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
encryptedMigrator = mock<EncryptedMigrator>();
logService = mock<LogService>();
service = new DefaultLoginSuccessHandlerService(
@@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService,
syncService,
userAsymmetricKeysRegenerationService,
encryptedMigrator,
logService,
);
@@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
describe("run", () => {
it("should call required services on successful login", async () => {
await service.run(userId);
await service.run(userId, null);
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
@@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
});
it("should get SSO email", async () => {
await service.run(userId);
await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
});
@@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService.getSsoEmail.mockResolvedValue(null);
});
it("should log error and return early", async () => {
await service.run(userId);
it("should not check SSO requirements", async () => {
await service.run(userId, null);
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
@@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
});
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
await service.run(userId);
await service.run(userId, null);
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();

View File

@@ -1,4 +1,5 @@
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
@@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
private ssoLoginService: SsoLoginServiceAbstraction,
private syncService: SyncService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private encryptedMigrator: EncryptedMigrator,
private logService: LogService,
) {}
async run(userId: UserId): Promise<void> {
async run(userId: UserId, masterPassword: string | null): Promise<void> {
await this.syncService.fullSync(true, { skipTokenRefresh: true });
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await this.loginEmailService.clearLoginEmail();
try {
await this.encryptedMigrator.runMigrations(userId, masterPassword);
} catch {
// Don't block login success on migration failure
}
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();

View File

@@ -37,6 +37,8 @@ export class FakeAccountService implements AccountService {
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
showHeaderSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId;
get activeUserId() {
return this._activeUserId;
@@ -55,6 +57,7 @@ export class FakeAccountService implements AccountService {
}),
);
}
showHeader$ = this.showHeaderSubject.asObservable();
get nextUpAccount$(): Observable<Account> {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
@@ -114,6 +117,10 @@ export class FakeAccountService implements AccountService {
this.accountsSubject.next(updated);
await this.mock.clean(userId);
}
async setShowHeader(value: boolean): Promise<void> {
this.showHeaderSubject.next(value);
}
}
const loggedOutInfo: AccountInfo = {

View File

@@ -2,7 +2,10 @@
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { KeyService } from "@bitwarden/key-management";
import { EncryptionType } from "../src/platform/enums";
import { Utils } from "../src/platform/misc/utils";
@@ -29,6 +32,7 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
export function mockEnc(s: string): MockProxy<EncString> {
const mocked = mock<EncString>();
mocked.decryptedValue = s;
mocked.decrypt.mockResolvedValue(s);
return mocked;
@@ -77,4 +81,14 @@ export const mockFromSdk = (stub: any) => {
return `${stub}_fromSdk`;
};
export const mockContainerService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptString.mockImplementation(async (encStr, _key) => {
return encStr.decryptedValue;
});
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
return (window as any).bitwardenContainerService;
};
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";

View File

@@ -41,6 +41,18 @@ export function canAccessBillingTab(org: Organization): boolean {
return org.isOwner;
}
/**
* Access Intelligence is only available to:
* - Enterprise organizations
* - Users in those organizations with report access
*
* @param org The organization to verify access
* @returns If true can access the Access Intelligence feature
*/
export function canAccessAccessIntelligence(org: Organization): boolean {
return org.canUseAccessIntelligence && org.canAccessReports;
}
export function canAccessOrgAdmin(org: Organization): boolean {
// Admin console can only be accessed by Owners for disabled organizations
if (!org.enabled && !org.isOwner) {

View File

@@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType;
data: Record<string, string | number | boolean>;
enabled: boolean;
revisionDate: string;
constructor(response?: PolicyResponse) {
if (response == null) {
@@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type;
this.data = response.data;
this.enabled = response.enabled;
this.revisionDate = response.revisionDate;
}
static fromPolicy(policy: Policy): PolicyData {

View File

@@ -402,4 +402,8 @@ export class Organization {
this.permissions.accessEventLogs)
);
}
get canUseAccessIntelligence() {
return this.productTierType === ProductTierType.Enterprise;
}
}

View File

@@ -19,6 +19,8 @@ export class Policy extends Domain {
*/
enabled: boolean;
revisionDate: Date;
constructor(obj?: PolicyData) {
super();
if (obj == null) {
@@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type;
this.data = obj.data;
this.enabled = obj.enabled;
this.revisionDate = new Date(obj.revisionDate);
}
static fromResponse(response: PolicyResponse): Policy {

View File

@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any;
enabled: boolean;
canToggleState: boolean;
revisionDate: string;
constructor(response: any) {
super(response);
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -83,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
revisionDate: expect.any(Date),
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
});
});
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: false,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
policyData.revisionDate = new Date().toISOString();
return policyData;
}

View File

@@ -47,6 +47,8 @@ export abstract class AccountService {
abstract sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
abstract nextUpAccount$: Observable<Account>;
/** Observable to display the header */
abstract showHeader$: Observable<boolean>;
/**
* Updates the `accounts$` observable with the new account data.
*
@@ -100,6 +102,11 @@ export abstract class AccountService {
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Show the account switcher.
* @param value
*/
abstract setShowHeader(visible: boolean): Promise<void>;
}
export abstract class InternalAccountService extends AccountService {

View File

@@ -18,6 +18,8 @@ export class AuthResult {
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() {
return this.twoFactorProviders != null;

View File

@@ -26,7 +26,6 @@ export class IdentityTokenResponse extends BaseResponse {
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions?: UserDecryptionOptionsResponse;
@@ -70,7 +69,7 @@ export class IdentityTokenResponse extends BaseResponse {
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@@ -429,6 +429,16 @@ describe("accountService", () => {
},
);
});
describe("setShowHeader", () => {
it("should update _showHeader$ when setShowHeader is called", async () => {
expect(sut["_showHeader$"].value).toBe(true);
await sut.setShowHeader(false);
expect(sut["_showHeader$"].value).toBe(false);
});
});
});
});

View File

@@ -6,6 +6,7 @@ import {
distinctUntilChanged,
shareReplay,
combineLatest,
BehaviorSubject,
Observable,
switchMap,
filter,
@@ -84,6 +85,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
@@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor(
private messagingService: MessagingService,
@@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };

View File

@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let environmentService: MockProxy<EnvironmentService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually2025,
@@ -250,7 +255,7 @@ describe("DefaultSubscriptionPricingService", () => {
return "Custom";
// Plan descriptions
case "planDescPremium":
case "advancedOnlineSecurity":
return "Premium plan description";
case "planDescFamiliesV2":
return "Families plan description";
@@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
const setupEnvironmentService = (
envService: MockProxy<EnvironmentService>,
region: Region = Region.US,
) => {
envService.environment$ = of({
getRegion: () => region,
isCloud: () => region !== Region.SelfHosted,
} as any);
};
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
environmentService = mock<EnvironmentService>();
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService(
billingApiService,
configService,
i18nService,
logService,
environmentService,
);
});
@@ -397,7 +415,7 @@ describe("DefaultSubscriptionPricingService", () => {
});
expect(i18nService.t).toHaveBeenCalledWith("premium");
expect(i18nService.t).toHaveBeenCalledWith("planDescPremium");
expect(i18nService.t).toHaveBeenCalledWith("advancedOnlineSecurity");
expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies");
expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2");
expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator");
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response missing the Seat property
const malformedResponse = {
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response with price as string instead of number
const malformedResponse = {
Seat: {
StripePriceId: "price_seat",
Price: "10", // Should be a number
},
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
});
describe("Observable behavior and caching", () => {
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe to the premium pricing tier multiple times
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService(
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe with feature flag disabled
@@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
});
describe("Self-hosted environment behavior", () => {
it("should not call API for self-hosted environment", () => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
// Trigger subscriptions by calling the methods
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
// API should not be called for self-hosted environments
expect(getPlansSpy).not.toHaveBeenCalled();
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
});
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
expect(tiers).toHaveLength(2); // Premium and Families
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
expect(premiumTier).toBeDefined();
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.features).toBeDefined();
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
done();
});
});
});
});

View File

@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private environmentService: EnvironmentService,
) {}
/**
* Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request.
*/
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
}),
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
: from(this.billingApiService.getPlans()),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
: from(this.billingApiService.getPremiumPlan()).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
provided: premiumPlan.storage.provided,
seat: premiumPlan.seat?.price,
storage: premiumPlan.storage?.price,
provided: premiumPlan.storage?.provided,
})),
)
: of({
@@ -127,7 +145,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
description: this.i18nService.t("advancedOnlineSecurity"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
)!;
private families$: Observable<PersonalSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan.PasswordManager.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan?.PasswordManager?.baseSeats,
annualPrice: familiesPlan?.PasswordManager?.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
return {
id: BusinessSubscriptionPricingTierIds.Free,
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
passwordManager: {
type: "free",
features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
this.featureTranslations.limitedCollectionsV2(
freePlan?.PasswordManager?.maxCollections,
),
this.featureTranslations.alwaysFree(),
],
},
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
type: "free",
features: [
this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
],
},
};
}),
);
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
private teams$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
),
);
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
);
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private featureTranslations = {
builtInAuthenticator: () => ({
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"),
}),
limitedUsersV2: (users: number) => ({
limitedUsersV2: (users?: number) => ({
key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users),
}),
limitedCollectionsV2: (collections: number) => ({
limitedCollectionsV2: (collections?: number) => ({
key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections),
}),
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"),
}),
projectsIncludedV2: (projects: number) => ({
projectsIncludedV2: (projects?: number) => ({
key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects),
}),
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"),
}),
includedMachineAccountsV2: (included: number) => ({
includedMachineAccountsV2: (included?: number) => ({
key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included),
}),

View File

@@ -27,26 +27,26 @@ type HasFeatures = {
};
type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number;
annualPricePerAdditionalStorageGB?: number;
};
type HasProvidedStorage = {
providedStorageGB: number;
providedStorageGB?: number;
};
type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage &
HasProvidedStorage & {
type: "standalone";
annualPrice: number;
annualPrice?: number;
};
type PackagedPasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "packaged";
users: number;
annualPrice: number;
users?: number;
annualPrice?: number;
};
type FreePasswordManager = HasFeatures & {
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "scalable";
annualPricePerUser: number;
annualPricePerUser?: number;
};
type FreeSecretsManager = HasFeatures & {
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
type ScalableSecretsManager = HasFeatures & {
type: "scalable";
annualPricePerUser: number;
annualPricePerAdditionalServiceAccount: number;
annualPricePerUser?: number;
annualPricePerAdditionalServiceAccount?: number;
};
export type PersonalSubscriptionPricingTier = {

View File

@@ -35,5 +35,26 @@ describe("HibpApiService", () => {
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
});
it("should return empty array when no breaches found (REST semantics)", async () => {
// Server now returns 200 OK with empty array [] instead of 404
const mockResponse: any[] = [];
const username = "safe@example.com";
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getHibpBreach(username);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/hibp/breach?username=" + encodeURIComponent(username),
null,
true,
true,
);
expect(result).toEqual([]);
expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -14,6 +14,7 @@ export enum FeatureFlag {
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -42,6 +43,7 @@ export enum FeatureFlag {
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
/* Tools */
@@ -62,6 +64,8 @@ export enum FeatureFlag {
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -96,6 +100,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
@@ -119,6 +124,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
@@ -143,6 +150,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */

View File

@@ -91,7 +91,7 @@ export abstract class CryptoFunctionService {
abstract rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
@@ -100,10 +100,10 @@ export abstract class CryptoFunctionService {
abstract rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>;
/**
* Generates a key of the given length suitable for use in AES encryption
*/

View File

@@ -252,15 +252,9 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
}
let algorithm: "sha1" | "sha256";
switch (data.encryptionType) {
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
algorithm = "sha1";
break;
case EncryptionType.Rsa2048_OaepSha256_B64:
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
algorithm = "sha256";
break;
default:
throw new Error("Invalid encryption type.");
@@ -270,6 +264,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
}
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
}
}

View File

@@ -299,7 +299,6 @@ describe("WebCrypto Function Service", () => {
});
describe("rsaGenerateKeyPair", () => {
testRsaGenerateKeyPair(1024);
testRsaGenerateKeyPair(2048);
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
@@ -495,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
});
}
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
function testRsaGenerateKeyPair(length: 2048) {
it(
"should successfully generate a " + length + " bit key pair",
async () => {

View File

@@ -263,33 +263,19 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
async rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
const buffer = await this.subtle.encrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_encrypt_data(data, publicKey);
}
async rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
const buffer = await this.subtle.decrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_decrypt_data(data, privateKey);
}
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
@@ -297,6 +283,13 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey;
}
async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> {
await SdkLoadService.Ready;
const privateKey = PureCrypto.rsa_generate_keypair();
const publicKey = await this.rsaExtractPublicKey(privateKey);
return [publicKey, privateKey];
}
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
if (bitLength === 512) {
// 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys
@@ -314,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return new Uint8Array(rawKey) as CsprngArray;
}
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
const rsaParams = {
name: "RSA-OAEP",
modulusLength: length,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
// Have to specify some algorithm
hash: { name: this.toWebCryptoAlgorithm("sha1") },
};
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];
}
randomBytes(length: number): Promise<CsprngArray> {
const arr = new Uint8Array(length);
this.crypto.getRandomValues(arr);

View File

@@ -0,0 +1,194 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
import { EncryptedMigration } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
jest.mock("./migrations/minimum-kdf-migration");
describe("EncryptedMigrator", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const configService = mock<ConfigService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const syncService = mock<SyncService>();
let sut: DefaultEncryptedMigrator;
const mockMigration = mock<MinimumKdfMigration>();
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword123";
beforeEach(() => {
jest.clearAllMocks();
// Mock the MinimumKdfMigration constructor to return our mock
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
() => mockMigration,
);
sut = new DefaultEncryptedMigrator(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
configService,
masterPasswordService,
syncService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("runMigrations", () => {
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
});
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run migration when needsMigration returns 'needsMigration'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should throw error when migration needs master password but null is provided", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run multiple migrations", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("needsMigrations", () => {
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when at least one migration needs to run", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
});
});
});

View File

@@ -0,0 +1,113 @@
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../auth/utils";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
export class DefaultEncryptedMigrator implements EncryptedMigrator {
private migrations: { name: string; migration: EncryptedMigration }[] = [];
private isRunningMigration = false;
constructor(
readonly kdfConfigService: KdfConfigService,
readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
readonly configService: ConfigService,
readonly masterPasswordService: MasterPasswordServiceAbstraction,
readonly syncService: SyncService,
) {
// Register migrations here
this.migrations.push({
name: "Minimum PBKDF2 Iteration Count Migration",
migration: new MinimumKdfMigration(
kdfConfigService,
changeKdfService,
logService,
configService,
masterPasswordService,
),
});
}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
// Ensure that the requirements for running all migrations are met
const needsMigration = await this.needsMigrations(userId);
if (needsMigration === "noMigrationNeeded") {
return;
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
// password, such as biometric unlock, the migrations are skipped.
//
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
return;
}
try {
// No concurrent migrations allowed, so acquire a service-wide lock
if (this.isRunningMigration) {
return;
}
this.isRunningMigration = true;
// Run all migrations sequentially in the order they were registered
this.logService.mark("[Encrypted Migrator] Start");
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
let ranMigration = false;
for (const { name, migration } of this.migrations) {
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
const start = performance.now();
await migration.runMigrations(userId, masterPassword);
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
ranMigration = true;
}
}
this.logService.mark("[Encrypted Migrator] Finish");
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
if (ranMigration) {
await this.syncService.fullSync(true);
}
} catch (error) {
this.logService.error(
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
error,
);
throw error; // Re-throw the error to be handled by the caller
} finally {
this.isRunningMigration = false;
}
}
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
const migrationRequirements = await Promise.all(
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
);
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
return "needsMigrationWithMasterPassword";
} else if (migrationRequirements.includes("needsMigration")) {
return "needsMigration";
} else {
return "noMigrationNeeded";
}
}
isRunningMigrations(): boolean {
return this.isRunningMigration;
}
}

View File

@@ -0,0 +1,32 @@
import { UserId } from "../../types/guid";
import { MigrationRequirement } from "./migrations/encrypted-migration";
export abstract class EncryptedMigrator {
/**
* Runs migrations on a decrypted user, with the cryptographic state initialized.
* This only runs the migrations that are needed for the user.
* This needs to be run after the decrypted user key has been set to state.
*
* If the master password is required but not provided, the migrations will not run, and the function will return early.
* If migrations are already running, the migrations will not run again, and the function will return early.
*
* @param userId The ID of the user to run migrations for.
* @param masterPassword The user's current master password.
* @throws If the user does not exist
* @throws If the user is locked or logged out
* @throws If a migration fails
*/
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Checks if the user needs to run any migrations.
* This is used to determine if the user should be prompted to run migrations.
* @param userId The ID of the user to check migrations for.
*/
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
/**
* Indicates whether migrations are currently running.
*/
abstract isRunningMigrations(): boolean;
}

View File

@@ -0,0 +1,36 @@
import { UserId } from "../../../types/guid";
/**
* @internal
* IMPORTANT: Please read this when implementing new migrations.
*
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
* during actions such as login and unlock, or during sync.
*
* Migrations can require the master-password, which is provided by the user if required.
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
*
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
* are made for the server, and other devices may run the migration concurrently.
*
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
*/
export interface EncryptedMigration {
/**
* Runs the migration.
* @throws If the migration fails, such as when no network is available.
* @throws If the requirements for migration are not met (e.g. the user is locked)
*/
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
*/
needsMigration(userId: UserId): Promise<MigrationRequirement>;
}
export type MigrationRequirement =
| "needsMigration"
| "needsMigrationWithMasterPassword"
| "noMigrationNeeded";

View File

@@ -0,0 +1,184 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import {
Argon2KdfConfig,
KdfConfigService,
KdfType,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { UserId } from "../../../types/guid";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { MinimumKdfMigration } from "./minimum-kdf-migration";
describe("MinimumKdfMigration", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const mockConfigService = mock<ConfigService>();
const mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
let sut: MinimumKdfMigration;
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword";
beforeEach(() => {
jest.clearAllMocks();
sut = new MinimumKdfMigration(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
mockConfigService,
mockMasterPasswordService,
);
});
describe("needsMigration", () => {
it("should return 'noMigrationNeeded' when user does not have a master password`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when user uses argon2id`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true);
mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4));
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when feature flag is disabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigration(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId");
});
});
describe("runMigrations", () => {
it("should update KDF parameters with minimum PBKDF2 iterations", async () => {
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
// Verify the PBKDF2KdfConfig has the correct iteration count
const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1];
expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue);
});
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow(
"userId",
);
});
it("should throw error when masterPassword is null", async () => {
await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword");
});
it("should throw error when masterPassword is undefined", async () => {
await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow(
"masterPassword",
);
});
it("should handle errors from changeKdfService", async () => {
const mockError = new Error("KDF update failed");
mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError);
await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow(
"KDF update failed",
);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
});
});
});

View File

@@ -0,0 +1,68 @@
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../../auth/utils";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration";
/**
* @internal
* This migrator ensures the user's account has a minimum PBKDF2 iteration count.
* It will update the entire account, logging out old clients if necessary.
*/
export class MinimumKdfMigration implements EncryptedMigration {
constructor(
private readonly kdfConfigService: KdfConfigService,
private readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
private readonly configService: ConfigService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
) {}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
assertNonNullish(masterPassword, "masterPassword");
this.logService.info(
`[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`,
);
await this.changeKdfService.updateUserKdfParams(
masterPassword!,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
userId,
);
await this.kdfConfigService.setKdfConfig(
userId,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
);
}
async needsMigration(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
if (!(await this.masterPasswordService.userHasMasterPassword(userId))) {
return "noMigrationNeeded";
}
// Only PBKDF2 users below the minimum iteration count need migration
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
if (
kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 ||
kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min
) {
return "noMigrationNeeded";
}
if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) {
return "noMigrationNeeded";
}
return "needsMigrationWithMasterPassword";
}
}

View File

@@ -17,7 +17,7 @@ import {
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "./change-kdf-service";
import { DefaultChangeKdfService } from "./change-kdf.service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();

View File

@@ -14,7 +14,7 @@ import {
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { ChangeKdfService } from "./change-kdf-service.abstraction";
import { ChangeKdfService } from "./change-kdf.service.abstraction";
export class DefaultChangeKdfService implements ChangeKdfService {
constructor(

View File

@@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction {
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
) => Promise<UserKey>;
/**
* Returns whether the user has a master password set.
* @param userId The user ID.
* @throws If the user ID is missing.
*/
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
}
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {

View File

@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
this.masterKeyHashSubject.next(initialMasterKeyHash);
}
userHasMasterPassword(userId: UserId): Promise<boolean> {
return this.mock.userHasMasterPassword(userId);
}
emailToSalt(email: string): MasterPasswordSalt {
return this.mock.emailToSalt(email);
}

View File

@@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key";
import { KeyGenerationService } from "../../crypto";
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
@@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
private accountService: AccountService,
) {}
async userHasMasterPassword(userId: UserId): Promise<boolean> {
assertNonNullish(userId, "userId");
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
const usesKeyConnector = await firstValueFrom(
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
);
const usesMasterKey = await firstValueFrom(
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
);
return usesMasterKey && !usesKeyConnector;
}
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
assertNonNullish(userId, "userId");
return this.accountService.accounts$.pipe(
@@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
masterPasswordUnlockData.kdf.toSdkConfig(),
),
);
return userKey as UserKey;
}

View File

@@ -0,0 +1,15 @@
import { VaultTimeout } from "../../vault-timeout";
export abstract class SessionTimeoutTypeService {
/**
* Is provided timeout type available on this client type, OS ?
* @param timeout the timeout type
*/
abstract isAvailable(timeout: VaultTimeout): Promise<boolean>;
/**
* Returns the highest available and permissive timeout type, that is higher than or equals the provided timeout type.
* @param timeout the provided timeout type
*/
abstract getOrPromoteToAvailable(timeout: VaultTimeout): Promise<VaultTimeout>;
}

View File

@@ -0,0 +1,3 @@
export { SessionTimeoutTypeService } from "./abstractions/session-timeout-type.service";
export { MaximumSessionTimeoutPolicyData } from "./types/maximum-session-timeout-policy.type";
export { SessionTimeoutAction, SessionTimeoutType } from "./types/session-timeout.type";

View File

@@ -0,0 +1,7 @@
import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type";
export interface MaximumSessionTimeoutPolicyData {
type?: SessionTimeoutType;
minutes: number;
action?: SessionTimeoutAction;
}

View File

@@ -0,0 +1,8 @@
export type SessionTimeoutAction = null | "lock" | "logOut";
export type SessionTimeoutType =
| null
| "never"
| "onAppRestart"
| "onSystemLock"
| "immediately"
| "custom";

View File

@@ -1,6 +1,10 @@
import { Opaque } from "type-fest";
import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal";
import {
EncString,
SignedSecurityState as SdkSignedSecurityState,
SignedPublicKey as SdkSignedPublicKey,
} from "@bitwarden/sdk-internal";
/**
* A private key, encrypted with a symmetric key.
@@ -10,7 +14,7 @@ export type WrappedPrivateKey = Opaque<EncString, "WrappedPrivateKey">;
/**
* A public key, signed with the accounts signature key.
*/
export type SignedPublicKey = Opaque<string, "SignedPublicKey">;
export type SignedPublicKey = Opaque<SdkSignedPublicKey, "SignedPublicKey">;
/**
* A public key in base64 encoded SPKI-DER
*/

View File

@@ -4,8 +4,9 @@ export { VaultTimeoutService } from "./abstractions/vault-timeout.service";
export { VaultTimeoutService as DefaultVaultTimeoutService } from "./services/vault-timeout.service";
export { VaultTimeoutAction } from "./enums/vault-timeout-action.enum";
export {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutOption,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "./types/vault-timeout.type";
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";

View File

@@ -21,9 +21,14 @@ import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
import { SessionTimeoutTypeService } from "../../session-timeout";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
import {
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "../types/vault-timeout.type";
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
@@ -40,9 +45,11 @@ describe("VaultTimeoutSettingsService", () => {
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
const mockUserId = Utils.newGuid() as UserId;
let stateProvider: FakeStateProvider;
let logService: MockProxy<LogService>;
let sessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId);
@@ -67,8 +74,8 @@ describe("VaultTimeoutSettingsService", () => {
stateProvider = new FakeStateProvider(accountService);
logService = mock<LogService>();
sessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
biometricStateService.biometricUnlockEnabled$ = of(false);
@@ -259,40 +266,276 @@ describe("VaultTimeoutSettingsService", () => {
);
});
it.each([
// policy, vaultTimeout, expected
[null, null, 15], // no policy, no vault timeout, falls back to default
[30, 90, 30], // policy overrides vault timeout
[30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range
[90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never"
[null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout
[90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate)
[null, 0, 0], // no policy, persist 0 (immediate) vault timeout
[90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart"
[null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout
[90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked"
[null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout
[90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep"
[null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout
[90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle"
[null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout
])(
"when policy is %s, and vault timeout is %s, returns %s",
async (policy, vaultTimeout, expected) => {
describe("no policy", () => {
it("when vault timeout is null, returns default", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, null, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(defaultVaultTimeout);
});
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])("when vault timeout is %s, returns unchanged", async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(expected);
},
);
expect(result).toBe(vaultTimeout);
});
});
describe("policy type: custom", () => {
const policyMinutes = 30;
it.each([
VaultTimeoutNumberType.EightHours,
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnSleep,
VaultTimeoutStringType.OnIdle,
])(
"when vault timeout is %s and exceeds policy max, returns policy minutes",
async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(policyMinutes);
},
);
it.each([VaultTimeoutNumberType.OnMinute, policyMinutes])(
"when vault timeout is %s and within policy max, returns unchanged",
async (vaultTimeout) => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(vaultTimeout);
},
);
it("when vault timeout is Immediately, returns Immediately", async () => {
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.Immediately,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
});
describe("policy type: immediately", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])(
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(expectedTimeout);
},
);
});
describe("policy type: onSystemLock", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
])(
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(expectedTimeout);
},
);
it.each([
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is numeric %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
describe("policy type: onAppRestart", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
])("when current timeout is %s, returns onRestart", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
);
expect(result).toBe(expectedTimeout);
});
it.each([
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
VaultTimeoutStringType.OnSleep,
VaultTimeoutNumberType.Immediately,
VaultTimeoutNumberType.OnMinute,
VaultTimeoutNumberType.EightHours,
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(result).toBe(currentTimeout);
});
});
});
describe("setVaultTimeoutOptions", () => {
@@ -405,6 +648,7 @@ describe("VaultTimeoutSettingsService", () => {
stateProvider,
logService,
defaultVaultTimeout,
sessionTimeoutTypeService,
);
}
});

View File

@@ -1,14 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
EMPTY,
Observable,
catchError,
combineLatest,
defer,
distinctUntilChanged,
EMPTY,
firstValueFrom,
from,
map,
Observable,
shareReplay,
switchMap,
tap,
@@ -23,7 +24,6 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy";
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { TokenService } from "../../../auth/abstractions/token.service";
@@ -31,9 +31,15 @@ import { LogService } from "../../../platform/abstractions/log.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
import { MaximumSessionTimeoutPolicyData, SessionTimeoutTypeService } from "../../session-timeout";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
import {
isVaultTimeoutTypeNumeric,
VaultTimeout,
VaultTimeoutNumberType,
VaultTimeoutStringType,
} from "../types/vault-timeout.type";
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
@@ -49,6 +55,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private stateProvider: StateProvider,
private logService: LogService,
private defaultVaultTimeout: VaultTimeout,
private sessionTimeoutTypeService: SessionTimeoutTypeService,
) {}
async setVaultTimeoutOptions(
@@ -131,11 +138,25 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return combineLatest([
this.stateProvider.getUserState$(VAULT_TIMEOUT, userId),
this.getMaxVaultTimeoutPolicyByUserId$(userId),
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
]).pipe(
switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => {
return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe(
switchMap(([currentVaultTimeout, maxSessionTimeoutPolicyData]) => {
this.logService.debug(
"[VaultTimeoutSettingsService] Current vault timeout is %o for user id %s, max session policy %o",
currentVaultTimeout,
userId,
maxSessionTimeoutPolicyData,
);
return from(
this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData),
).pipe(
tap((vaultTimeout: VaultTimeout) => {
this.logService.debug(
"[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s",
vaultTimeout,
userId,
);
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
if (vaultTimeout !== currentVaultTimeout) {
return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
@@ -155,28 +176,63 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxVaultTimeoutPolicy: Policy | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
// If no policy applies, return the current vault timeout
if (!maxVaultTimeoutPolicy) {
if (maxSessionTimeoutPolicyData == null) {
return currentVaultTimeout;
}
// User is subject to a max vault timeout policy
const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data;
// If the current vault timeout is not numeric, change it to the policy compliant value
if (typeof currentVaultTimeout === "string") {
return maxVaultTimeoutPolicyData.minutes;
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
case "custom":
case null:
case undefined:
if (currentVaultTimeout === VaultTimeoutNumberType.Immediately) {
return currentVaultTimeout;
}
if (isVaultTimeoutTypeNumeric(currentVaultTimeout)) {
return Math.min(currentVaultTimeout as number, maxSessionTimeoutPolicyData.minutes);
}
return maxSessionTimeoutPolicyData.minutes;
case "onSystemLock":
if (
currentVaultTimeout === VaultTimeoutStringType.Never ||
currentVaultTimeout === VaultTimeoutStringType.OnRestart ||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
}
break;
case "onAppRestart":
if (
currentVaultTimeout === VaultTimeoutStringType.Never ||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return VaultTimeoutStringType.OnRestart;
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
break;
}
// For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy
const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes);
return policyCompliantTimeout;
return currentVaultTimeout;
}
private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise<void> {
@@ -198,14 +254,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return combineLatest([
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
this.getMaxVaultTimeoutPolicyByUserId$(userId),
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
]).pipe(
switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => {
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
return from(
this.determineVaultTimeoutAction(
userId,
currentVaultTimeoutAction,
maxVaultTimeoutPolicy,
maxSessionTimeoutPolicyData,
),
).pipe(
tap((vaultTimeoutAction: VaultTimeoutAction) => {
@@ -235,7 +291,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeoutAction(
userId: string,
currentVaultTimeoutAction: VaultTimeoutAction | null,
maxVaultTimeoutPolicy: Policy | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeoutAction> {
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
if (availableVaultTimeoutActions.length === 1) {
@@ -243,11 +299,13 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
if (
maxVaultTimeoutPolicy?.data?.action &&
availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action)
maxSessionTimeoutPolicyData?.action &&
availableVaultTimeoutActions.includes(
maxSessionTimeoutPolicyData.action as VaultTimeoutAction,
)
) {
// return policy defined vault timeout action
return maxVaultTimeoutPolicy.data.action;
// return policy defined session timeout action
return maxSessionTimeoutPolicyData.action as VaultTimeoutAction;
}
// No policy applies from here on
@@ -262,14 +320,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
return currentVaultTimeoutAction;
}
private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable<Policy | null> {
private getMaxSessionTimeoutPolicyDataByUserId$(
userId: UserId,
): Observable<MaximumSessionTimeoutPolicyData | null> {
if (!userId) {
throw new Error("User id required. Cannot get max vault timeout policy.");
throw new Error("User id required. Cannot get max session timeout policy.");
}
return this.policyService
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
.pipe(getFirstPolicy);
return this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId).pipe(
getFirstPolicy,
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
);
}
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {

View File

@@ -1,6 +0,0 @@
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
export interface MaximumVaultTimeoutPolicyData {
minutes: number;
action?: VaultTimeoutAction;
}

View File

@@ -5,13 +5,25 @@ export const VaultTimeoutStringType = {
OnLocked: "onLocked", // -2
OnSleep: "onSleep", // -3
OnIdle: "onIdle", // -4
Custom: "custom", // -100
} as const;
export const VaultTimeoutNumberType = {
Immediately: 0,
OnMinute: 1,
EightHours: 480,
} as const;
export type VaultTimeout =
| number // 0 or positive numbers only
| (typeof VaultTimeoutNumberType)[keyof typeof VaultTimeoutNumberType]
| number // 0 or positive numbers (in minutes). See VaultTimeoutNumberType for common numeric presets
| (typeof VaultTimeoutStringType)[keyof typeof VaultTimeoutStringType];
export interface VaultTimeoutOption {
name: string;
value: VaultTimeout;
}
export function isVaultTimeoutTypeNumeric(timeout: VaultTimeout): boolean {
return typeof timeout === "number";
}

View File

@@ -138,7 +138,7 @@ export interface Fido2AuthenticatorGetAssertionParams {
rpId: string;
/** The hash of the serialized client data, provided by the client. */
hash: BufferSource;
allowCredentialDescriptorList: PublicKeyCredentialDescriptor[];
allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[];
/** The effective user verification requirement for assertion, a Boolean value provided by the client. */
requireUserVerification: boolean;
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */

View File

@@ -95,7 +95,7 @@ export abstract class Fido2UserInterfaceSession {
*/
abstract confirmNewCredential(
params: NewCredentialParams,
): Promise<{ cipherId: string; userVerified: boolean }>;
): Promise<{ cipherId?: string; userVerified: boolean }>;
/**
* Make sure that the vault is unlocked.

View File

@@ -1,4 +1,4 @@
import { init_sdk } from "@bitwarden/sdk-internal";
import { init_sdk, LogLevel } from "@bitwarden/sdk-internal";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
import type { SdkService } from "./sdk.service";
@@ -10,6 +10,7 @@ export class SdkLoadFailedError extends Error {
}
export abstract class SdkLoadService {
protected static logLevel: LogLevel = LogLevel.Info;
private static markAsReady: () => void;
private static markAsFailed: (error: unknown) => void;
@@ -41,7 +42,7 @@ export abstract class SdkLoadService {
async loadAndInit(): Promise<void> {
try {
await this.load();
init_sdk();
init_sdk(SdkLoadService.logLevel);
SdkLoadService.markAsReady();
} catch (error) {
SdkLoadService.markAsFailed(error);

View File

@@ -73,14 +73,13 @@ export default class Domain {
domain: DomainEncryptableKeys<D>,
viewModel: ViewEncryptableKeys<V>,
props: EncryptableKeys<D, V>[],
orgId: string | null,
key: SymmetricCryptoKey | null = null,
objectContext: string = "No Domain Context",
): Promise<V> {
for (const prop of props) {
viewModel[prop] =
(await domain[prop]?.decrypt(
orgId,
null,
key,
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
)) ?? null;

View File

@@ -1,3 +1,9 @@
import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
import { Fido2Utils } from "./fido2-utils";
describe("Fido2 Utils", () => {
@@ -67,4 +73,62 @@ describe("Fido2 Utils", () => {
expect(expectedArray).toBeNull();
});
});
describe("cipherHasNoOtherPasskeys(...)", () => {
const emptyPasskeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [],
},
});
const passkeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-1",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-2",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
],
},
});
it("should return true when there is no userHandle", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy();
});
it("should return true when userHandle matches", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy();
});
it("should return false when userHandle doesn't match", () => {
const userHandle = "testing";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy();
});
});
});

View File

@@ -1,3 +1,5 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// FIXME: Update this file to be type safe and remove this and next line
import type {
AssertCredentialResult,
@@ -111,4 +113,16 @@ export class Fido2Utils {
return output;
}
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
}

View File

@@ -1,28 +1,63 @@
import { guidToRawFormat } from "./guid-utils";
import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";
const workingExamples: [string, Uint8Array][] = [
[
"00000000-0000-0000-0000-000000000000",
new Uint8Array([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
]),
],
[
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
]),
],
];
describe("guid-utils", () => {
describe("guidToRawFormat", () => {
it.each(workingExamples)(
"returns UUID in binary format when given a valid UUID string",
(input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"00000000-0000-0000-0000-000000000000",
[
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
],
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
[
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
],
],
])("returns UUID in binary format when given a valid UUID string", (input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(new Uint8Array(expected));
"invalid",
"",
"",
"00000000-0000-0000-0000-0000000000000000",
"00000000-0000-0000-0000-000000",
])("throws an error when given an invalid UUID string", (input) => {
expect(() => guidToRawFormat(input)).toThrow(TypeError);
});
});
it("throws an error when given an invalid UUID string", () => {
expect(() => guidToRawFormat("invalid")).toThrow(TypeError);
describe("guidToStandardFormat", () => {
it.each(workingExamples)(
"returns UUID in standard format when given a valid UUID array buffer",
(expected, input) => {
const result = guidToStandardFormat(input);
expect(result).toEqual(expected);
},
);
it.each([
new Uint8Array(),
new Uint8Array([]),
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7,
]),
])("throws an error when given an invalid UUID array buffer", (input) => {
expect(() => guidToStandardFormat(input)).toThrow(TypeError);
});
});
});

View File

@@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) {
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
export function guidToStandardFormat(bufferSource: BufferSource) {
if (bufferSource.byteLength !== 16) {
throw TypeError("BufferSource length is invalid");
}
const arr =
bufferSource instanceof ArrayBuffer
? new Uint8Array(bufferSource)

View File

@@ -1,5 +1,5 @@
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
import { WrappedSigningKey } from "../../../key-management/types";
import { SignedPublicKey, 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";
@@ -35,3 +35,12 @@ export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition<WrappedSigni
clearOn: ["logout"],
},
);
export const USER_SIGNED_PUBLIC_KEY = new UserKeyDefinition<SignedPublicKey>(
CRYPTO_DISK,
"userSignedPublicKey",
{
deserializer: (obj) => obj,
clearOn: ["logout"],
},
);

View File

@@ -105,6 +105,7 @@ describe("DefaultSdkService", () => {
.mockReturnValue(of("private-key" as EncryptedString));
keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({}));
keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null));
keyService.userSignedPublicKey$.calledWith(userId).mockReturnValue(of(null));
securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null));
});

View File

@@ -16,6 +16,7 @@ import {
} from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKey } from "@bitwarden/common/types/key";
// 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, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
@@ -24,15 +25,14 @@ import {
ClientSettings,
TokenProvider,
UnsignedSharedKey,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { 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";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
@@ -174,6 +174,9 @@ export class DefaultSdkService implements SdkService {
const securityState$ = this.securityStateService
.accountSecurityState$(userId)
.pipe(distinctUntilChanged(compareValues));
const signedPublicKey$ = this.keyService
.userSignedPublicKey$(userId)
.pipe(distinctUntilChanged(compareValues));
const client$ = combineLatest([
this.environmentService.getEnvironment$(userId),
@@ -184,11 +187,22 @@ export class DefaultSdkService implements SdkService {
signingKey$,
orgKeys$,
securityState$,
signedPublicKey$,
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, signingKey, orgKeys, securityState]) => {
([
env,
account,
kdfParams,
privateKey,
userKey,
signingKey,
orgKeys,
securityState,
signedPublicKey,
]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
const createAndInitializeClient = async () => {
@@ -202,15 +216,31 @@ export class DefaultSdkService implements SdkService {
settings,
);
let accountCryptographicState: WrappedAccountCryptographicState;
if (signingKey != null && securityState != null && signedPublicKey != null) {
accountCryptographicState = {
V2: {
private_key: privateKey,
signing_key: signingKey,
security_state: securityState,
signed_public_key: signedPublicKey,
},
};
} else {
accountCryptographicState = {
V1: {
private_key: privateKey,
},
};
}
await this.initializeClient(
userId,
client,
account,
kdfParams,
privateKey,
userKey,
signingKey,
securityState,
accountCryptographicState,
orgKeys,
);
@@ -245,10 +275,8 @@ export class DefaultSdkService implements SdkService {
client: PasswordManagerClient,
account: AccountInfo,
kdfParams: KdfConfig,
privateKey: EncryptedString,
userKey: UserKey,
signingKey: WrappedSigningKey | null,
securityState: SignedSecurityState | null,
accountCryptographicState: WrappedAccountCryptographicState,
orgKeys: Record<OrganizationId, EncString>,
) {
await client.crypto().initialize_user_crypto({
@@ -265,9 +293,7 @@ export class DefaultSdkService implements SdkService {
parallelism: kdfParams.parallelism,
},
},
privateKey,
signingKey: signingKey || undefined,
securityState: securityState || undefined,
accountCryptographicState: accountCryptographicState,
});
// We initialize the org crypto even if the org_keys are

View File

@@ -253,6 +253,10 @@ export class DefaultSyncService extends CoreSyncService {
response.accountKeys.securityState.securityState,
response.id,
);
await this.keyService.setSignedPublicKey(
response.accountKeys.publicKeyEncryptionKeyPair.signedPublicKey,
response.id,
);
}
} else {
await this.keyService.setPrivateKey(response.privateKey, response.id);

View File

@@ -1,7 +1,6 @@
import { ApiService } from "../abstractions/api.service";
import { HibpApiService } from "../dirt/services/hibp-api.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "../models/response/error.response";
import { AuditService } from "./audit.service";
@@ -73,14 +72,16 @@ describe("AuditService", () => {
expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4);
});
it("should return empty array for breachedAccounts on 404", async () => {
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
it("should return empty array for breachedAccounts when no breaches found", async () => {
// Server returns 200 with empty array (correct REST semantics)
mockHibpApi.getHibpBreach.mockResolvedValueOnce([]);
const result = await auditService.breachedAccounts("user@example.com");
expect(result).toEqual([]);
});
it("should throw error for breachedAccounts on non-404 error", async () => {
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow();
it("should propagate errors from breachedAccounts", async () => {
const error = new Error("API error");
mockHibpApi.getHibpBreach.mockRejectedValueOnce(error);
await expect(auditService.breachedAccounts("user@example.com")).rejects.toBe(error);
});
});

View File

@@ -6,7 +6,6 @@ import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.s
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
import { HibpApiService } from "../dirt/services/hibp-api.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "../models/response/error.response";
import { Utils } from "../platform/misc/utils";
const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/";
@@ -70,14 +69,6 @@ export class AuditService implements AuditServiceAbstraction {
}
async breachedAccounts(username: string): Promise<BreachAccountResponse[]> {
try {
return await this.hibpApiService.getHibpBreach(username);
} catch (e) {
const error = e as ErrorResponse;
if (error.statusCode === 404) {
return [];
}
throw new Error();
}
return this.hibpApiService.getHibpBreach(username);
}
}

View File

@@ -1,6 +1,6 @@
import { mock } from "jest-mock-extended";
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendType } from "../../enums/send-type";
import { SendAccessResponse } from "../response/send-access.response";
@@ -23,6 +23,8 @@ describe("SendAccess", () => {
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
creatorIdentifier: "creatorIdentifier",
} as SendAccessResponse;
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -54,7 +54,7 @@ export class SendAccess extends Domain {
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
const model = new SendAccessView(this);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], key);
switch (this.type) {
case SendType.File:

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendFileData } from "../data/send-file.data";
import { SendFile } from "./send-file";
@@ -39,6 +39,7 @@ describe("SendFile", () => {
});
it("Decrypt", async () => {
mockContainerService();
const sendFile = new SendFile();
sendFile.id = "id";
sendFile.size = "1100";

View File

@@ -38,7 +38,6 @@ export class SendFile extends Domain {
this,
new SendFileView(this),
["fileName"],
null,
key,
);
}

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../../spec";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendTextData } from "../data/send-text.data";
import { SendText } from "./send-text";
@@ -11,6 +11,8 @@ describe("SendText", () => {
text: "encText",
hidden: false,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -30,13 +30,7 @@ export class SendText extends Domain {
}
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
return this.decryptObj<SendText, SendTextView>(
this,
new SendTextView(this),
["text"],
null,
key,
);
return this.decryptObj<SendText, SendTextView>(this, new SendTextView(this), ["text"], key);
}
static fromJSON(obj: Jsonify<SendText>) {

View File

@@ -6,7 +6,7 @@ import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc } from "../../../../../spec";
import { makeStaticByteArray, mockContainerService, mockEnc } from "../../../../../spec";
import { EncryptService } from "../../../../key-management/crypto/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../../platform/services/container.service";
@@ -43,6 +43,8 @@ describe("Send", () => {
disabled: false,
hideEmail: true,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -89,7 +89,7 @@ export class Send extends Domain {
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await keyService.makeSendKey(model.key);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
switch (this.type) {
case SendType.File:

View File

@@ -4,10 +4,11 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
export abstract class CipherArchiveService {
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
abstract hasArchiveFlagEnabled$: Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
abstract userHasPremium$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
}

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