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:
@@ -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]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -402,4 +402,8 @@ export class Organization {
|
||||
this.permissions.accessEventLogs)
|
||||
);
|
||||
}
|
||||
|
||||
get canUseAccessIntelligence() {
|
||||
return this.productTierType === ProductTierType.Enterprise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
3
libs/common/src/key-management/session-timeout/index.ts
Normal file
3
libs/common/src/key-management/session-timeout/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type";
|
||||
|
||||
export interface MaximumSessionTimeoutPolicyData {
|
||||
type?: SessionTimeoutType;
|
||||
minutes: number;
|
||||
action?: SessionTimeoutAction;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SessionTimeoutAction = null | "lock" | "logOut";
|
||||
export type SessionTimeoutType =
|
||||
| null
|
||||
| "never"
|
||||
| "onAppRestart"
|
||||
| "onSystemLock"
|
||||
| "immediately"
|
||||
| "custom";
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
|
||||
export interface MaximumVaultTimeoutPolicyData {
|
||||
minutes: number;
|
||||
action?: VaultTimeoutAction;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -38,7 +38,6 @@ export class SendFile extends Domain {
|
||||
this,
|
||||
new SendFileView(this),
|
||||
["fileName"],
|
||||
null,
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user