1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 09:33:22 +00:00

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import {
} from "rxjs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { UserKey } from "@bitwarden/common/types/key";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
@@ -24,15 +25,14 @@ import {
ClientSettings,
TokenProvider,
UnsignedSharedKey,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { ApiService } from "../../../abstractions/api.service";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
import { OrganizationId, UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
@@ -174,6 +174,9 @@ export class DefaultSdkService implements SdkService {
const securityState$ = this.securityStateService
.accountSecurityState$(userId)
.pipe(distinctUntilChanged(compareValues));
const signedPublicKey$ = this.keyService
.userSignedPublicKey$(userId)
.pipe(distinctUntilChanged(compareValues));
const client$ = combineLatest([
this.environmentService.getEnvironment$(userId),
@@ -184,11 +187,22 @@ export class DefaultSdkService implements SdkService {
signingKey$,
orgKeys$,
securityState$,
signedPublicKey$,
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
]).pipe(
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
switchMap(
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
([
env,
account,
kdfParams,
privateKey,
userKey,
signingKey,
orgKeys,
securityState,
signedPublicKey,
]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
const createAndInitializeClient = async () => {
@@ -202,15 +216,31 @@ export class DefaultSdkService implements SdkService {
settings,
);
let accountCryptographicState: WrappedAccountCryptographicState;
if (signingKey != null && securityState != null && signedPublicKey != null) {
accountCryptographicState = {
V2: {
private_key: privateKey,
signing_key: signingKey,
security_state: securityState,
signed_public_key: signedPublicKey,
},
};
} else {
accountCryptographicState = {
V1: {
private_key: privateKey,
},
};
}
await this.initializeClient(
userId,
client,
account,
kdfParams,
privateKey,
userKey,
signingKey,
securityState,
accountCryptographicState,
orgKeys,
);
@@ -245,10 +275,8 @@ export class DefaultSdkService implements SdkService {
client: PasswordManagerClient,
account: AccountInfo,
kdfParams: KdfConfig,
privateKey: EncryptedString,
userKey: UserKey,
signingKey: WrappedSigningKey | null,
securityState: SignedSecurityState | null,
accountCryptographicState: WrappedAccountCryptographicState,
orgKeys: Record<OrganizationId, EncString>,
) {
await client.crypto().initialize_user_crypto({
@@ -265,9 +293,7 @@ export class DefaultSdkService implements SdkService {
parallelism: kdfParams.parallelism,
},
},
privateKey,
signingKey: signingKey || undefined,
securityState: securityState || undefined,
accountCryptographicState: accountCryptographicState,
});
// We initialize the org crypto even if the org_keys are

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,10 @@ export abstract class CipherEncryptionService {
*
* @returns A promise that resolves to an array of decrypted cipher views
*/
abstract decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<CipherView[]>;
abstract decryptManyLegacy(
ciphers: Cipher[],
userId: UserId,
): Promise<[CipherView[], CipherView[]]>;
/**
* Decrypts many ciphers using the SDK for the given userId, and returns a list of
* failures.

View File

@@ -68,6 +68,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
/** When true, will override the match strategy for the cipher if it is Never. */
overrideNeverMatchStrategy?: true,
): Promise<CipherView[]>;
abstract getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]>;
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[],
url: string,
@@ -160,6 +161,17 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
userId: UserId,
admin?: boolean,
): Promise<Cipher>;
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher - The cipher with old attachments to upgrade
* @param userId - The user ID
* @param attachmentId - If provided, only upgrade the attachment with this ID
*/
abstract upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView>;
/**
* Save the collections for a cipher with the server
*
@@ -273,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
response: Response,
userId: UserId,
useLegacyDecryption?: boolean,
): Promise<Uint8Array | null>;
): Promise<Uint8Array>;
/**
* Decrypts the full `CipherView` for a given `CipherViewLike`.

View File

@@ -4,12 +4,10 @@ import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec";
import { makeStaticByteArray, mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../platform/services/container.service";
import { OrgKey, UserKey } from "../../../types/key";
import { AttachmentData } from "../../models/data/attachment.data";
import { Attachment } from "../../models/domain/attachment";
@@ -70,10 +68,9 @@ describe("Attachment", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const containerService = mockContainerService();
keyService = containerService.keyService as MockProxy<KeyService>;
encryptService = containerService.encryptService as MockProxy<EncryptService>;
});
it("expected output", async () => {
@@ -85,14 +82,13 @@ describe("Attachment", () => {
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
const userKey = new SymmetricCryptoKey(makeStaticByteArray(64));
keyService.getUserKey.mockResolvedValue(userKey as UserKey);
encryptService.decryptFileData.mockResolvedValue(makeStaticByteArray(32));
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
const view = await attachment.decrypt(null);
const userKey = new SymmetricCryptoKey(makeStaticByteArray(64));
const view = await attachment.decrypt(userKey);
expect(view).toEqual({
id: "id",
@@ -116,31 +112,11 @@ describe("Attachment", () => {
it("uses the provided key without depending on KeyService", async () => {
const providedKey = mock<SymmetricCryptoKey>();
await attachment.decrypt(null, "", providedKey);
await attachment.decrypt(providedKey, "");
expect(keyService.getUserKey).not.toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, providedKey);
});
it("gets an organization key if required", async () => {
const orgKey = mock<OrgKey>();
keyService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await attachment.decrypt("orgId", "", null);
expect(keyService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, orgKey);
});
it("gets the user's decryption key if required", async () => {
const userKey = mock<UserKey>();
keyService.getUserKey.mockResolvedValue(userKey);
await attachment.decrypt(null, "", null);
expect(keyService.getUserKey).toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, userKey);
});
});
});

View File

@@ -1,6 +1,5 @@
import { Jsonify } from "type-fest";
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
import { Attachment as SdkAttachment } from "@bitwarden/sdk-internal";
import { EncString } from "../../../key-management/crypto/models/enc-string";
@@ -34,21 +33,19 @@ export class Attachment extends Domain {
}
async decrypt(
orgId: string | undefined,
decryptionKey: SymmetricCryptoKey,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
new AttachmentView(this),
["fileName"],
orgId ?? null,
encKey,
decryptionKey,
"DomainType: Attachment; " + context,
);
if (this.key != null) {
view.key = await this.decryptAttachmentKey(orgId, encKey);
view.key = await this.decryptAttachmentKey(decryptionKey);
view.encryptedKey = this.key; // Keep the encrypted key for the view
// When the attachment key couldn't be decrypted, mark a decryption error
@@ -62,27 +59,15 @@ export class Attachment extends Domain {
}
private async decryptAttachmentKey(
orgId: string | undefined,
encKey?: SymmetricCryptoKey,
decryptionKey: SymmetricCryptoKey,
): Promise<SymmetricCryptoKey | undefined> {
try {
if (this.key == null) {
return undefined;
}
if (encKey == null) {
const key = await this.getKeyForDecryption(orgId);
// If we don't have a key, we can't decrypt
if (key == null) {
return undefined;
}
encKey = key;
}
const encryptService = Utils.getContainerService().getEncryptService();
const decValue = await encryptService.unwrapSymmetricKey(this.key, encKey);
const decValue = await encryptService.unwrapSymmetricKey(this.key, decryptionKey);
return decValue;
} catch (e) {
// eslint-disable-next-line no-console
@@ -91,11 +76,6 @@ export class Attachment extends Domain {
}
}
private async getKeyForDecryption(orgId: string | undefined): Promise<OrgKey | UserKey | null> {
const keyService = Utils.getContainerService().getKeyService();
return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey();
}
toAttachmentData(): AttachmentData {
const a = new AttachmentData();
if (this.size != null) {

View File

@@ -1,4 +1,9 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import {
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { CardData } from "../../../vault/models/data/card.data";
import { Card } from "../../models/domain/card";
@@ -65,7 +70,10 @@ describe("Card", () => {
card.expYear = mockEnc("expYear");
card.code = mockEnc("code");
const view = await card.decrypt(null);
const userKey = makeSymmetricCryptoKey(64);
mockContainerService();
const view = await card.decrypt(userKey);
expect(view).toEqual({
_brand: "brand",

View File

@@ -31,16 +31,11 @@ export class Card extends Domain {
this.code = conditionalEncString(obj.code);
}
async decrypt(
orgId: string | undefined,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
async decrypt(encKey: SymmetricCryptoKey, context = "No Cipher Context"): Promise<CardView> {
return this.decryptObj<Card, CardView>(
this,
new CardView(),
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
orgId ?? null,
encKey,
"DomainType: Card; " + context,
);

View File

@@ -2,9 +2,7 @@ import { mock } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-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 } from "@bitwarden/key-management";
import { MockProxy } from "@bitwarden/common/platform/spec/mock-deep";
import {
CipherType as SdkCipherType,
UriMatchType,
@@ -14,11 +12,15 @@ import {
EncString as SdkEncString,
} from "@bitwarden/sdk-internal";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
import {
makeStaticByteArray,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec/utils";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { ContainerService } from "../../../platform/services/container.service";
import { InitializerKey } from "../../../platform/services/cryptography/initializer-key";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../abstractions/cipher.service";
@@ -39,7 +41,16 @@ import { IdentityView } from "../../models/view/identity.view";
import { LoginView } from "../../models/view/login.view";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
const mockSymmetricKey = new SymmetricCryptoKey(makeStaticByteArray(64));
describe("Cipher DTO", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
const containerService = mockContainerService();
encryptService = containerService.encryptService;
});
it("Convert from empty CipherData", () => {
const data = new CipherData();
const cipher = new Cipher(data);
@@ -95,13 +106,12 @@ describe("Cipher DTO", () => {
login.decrypt.mockResolvedValue(loginView);
cipher.login = login;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("Failed to unwrap key"));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
cipherService.getKeyForCipherKeyDecryption.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
@@ -317,19 +327,11 @@ describe("Cipher DTO", () => {
login.decrypt.mockResolvedValue(loginView);
cipher.login = login;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -445,19 +447,11 @@ describe("Cipher DTO", () => {
cipher.permissions = new CipherPermissionsApi();
cipher.archivedDate = undefined;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -591,19 +585,11 @@ describe("Cipher DTO", () => {
card.decrypt.mockResolvedValue(cardView);
cipher.card = card;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",
@@ -761,19 +747,11 @@ describe("Cipher DTO", () => {
identity.decrypt.mockResolvedValue(identityView);
cipher.identity = identity;
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
const cipherService = mock<CipherService>();
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
const cipherView = await cipher.decrypt(
await cipherService.getKeyForCipherKeyDecryption(cipher, mockUserId),
);
const cipherView = await cipher.decrypt(mockSymmetricKey);
expect(cipherView).toMatchObject({
id: "id",

View File

@@ -1,5 +1,6 @@
import { Jsonify } from "type-fest";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { Cipher as SdkCipher } from "@bitwarden/sdk-internal";
import { EncString } from "../../../key-management/crypto/models/enc-string";
@@ -123,19 +124,22 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
}
// We are passing the organizationId into the EncString.decrypt() method here, but because the encKey will always be
// present and so the organizationId will not be used.
// We will refactor the EncString.decrypt() in https://bitwarden.atlassian.net/browse/PM-3762 to remove the dependency on the organizationId.
async decrypt(encKey: SymmetricCryptoKey): Promise<CipherView> {
async decrypt(userKeyOrOrgKey: SymmetricCryptoKey): Promise<CipherView> {
assertNonNullish(userKeyOrOrgKey, "userKeyOrOrgKey", "Cipher decryption");
const model = new CipherView(this);
let bypassValidation = true;
// By default, the user/organization key is used for decryption
let cipherDecryptionKey = userKeyOrOrgKey;
// If there is a cipher key present, unwrap it and use it for decryption
if (this.key != null) {
const encryptService = Utils.getContainerService().getEncryptService();
try {
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, encKey);
encKey = cipherKey;
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, userKeyOrOrgKey);
cipherDecryptionKey = cipherKey;
bypassValidation = false;
} catch {
model.name = "[error: cannot decrypt]";
@@ -144,22 +148,15 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
}
}
await this.decryptObj<Cipher, CipherView>(
this,
model,
["name", "notes"],
this.organizationId ?? null,
encKey,
);
await this.decryptObj<Cipher, CipherView>(this, model, ["name", "notes"], cipherDecryptionKey);
switch (this.type) {
case CipherType.Login:
if (this.login != null) {
model.login = await this.login.decrypt(
this.organizationId,
bypassValidation,
userKeyOrOrgKey,
`Cipher Id: ${this.id}`,
encKey,
);
}
break;
@@ -170,29 +167,17 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
break;
case CipherType.Card:
if (this.card != null) {
model.card = await this.card.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.card = await this.card.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
case CipherType.Identity:
if (this.identity != null) {
model.identity = await this.identity.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.identity = await this.identity.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
case CipherType.SshKey:
if (this.sshKey != null) {
model.sshKey = await this.sshKey.decrypt(
this.organizationId,
`Cipher Id: ${this.id}`,
encKey,
);
model.sshKey = await this.sshKey.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
}
break;
default:
@@ -203,9 +188,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
const attachments: AttachmentView[] = [];
for (const attachment of this.attachments) {
const decryptedAttachment = await attachment.decrypt(
this.organizationId,
userKeyOrOrgKey,
`Cipher Id: ${this.id}`,
encKey,
);
attachments.push(decryptedAttachment);
}
@@ -215,7 +199,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.fields != null && this.fields.length > 0) {
const fields: FieldView[] = [];
for (const field of this.fields) {
const decryptedField = await field.decrypt(this.organizationId, encKey);
const decryptedField = await field.decrypt(userKeyOrOrgKey);
fields.push(decryptedField);
}
model.fields = fields;
@@ -224,7 +208,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
const passwordHistory: PasswordHistoryView[] = [];
for (const ph of this.passwordHistory) {
const decryptedPh = await ph.decrypt(this.organizationId, encKey);
const decryptedPh = await ph.decrypt(userKeyOrOrgKey);
passwordHistory.push(decryptedPh);
}
model.passwordHistory = passwordHistory;

View File

@@ -1,4 +1,4 @@
import { mockEnc } from "../../../../spec";
import { makeSymmetricCryptoKey, mockContainerService, mockEnc } from "../../../../spec";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { EncryptionType } from "../../../platform/enums";
import { Fido2CredentialData } from "../data/fido2-credential.data";
@@ -103,7 +103,10 @@ describe("Fido2Credential", () => {
credential.discoverable = mockEnc("true");
credential.creationDate = mockDate;
const credentialView = await credential.decrypt(null);
mockContainerService();
const cipherKey = makeSymmetricCryptoKey(64);
const credentialView = await credential.decrypt(cipherKey);
expect(credentialView).toEqual({
credentialId: "credentialId",

View File

@@ -46,10 +46,7 @@ export class Fido2Credential extends Domain {
this.creationDate = new Date(obj.creationDate);
}
async decrypt(
orgId: string | undefined,
encKey?: SymmetricCryptoKey,
): Promise<Fido2CredentialView> {
async decrypt(decryptionKey: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
new Fido2CredentialView(),
@@ -65,8 +62,7 @@ export class Fido2Credential extends Domain {
"rpName",
"userDisplayName",
],
orgId ?? null,
encKey,
decryptionKey,
);
const { counter } = await this.decryptObj<
@@ -74,7 +70,7 @@ export class Fido2Credential extends Domain {
{
counter: string;
}
>(this, { counter: "" }, ["counter"], orgId ?? null, encKey);
>(this, { counter: "" }, ["counter"], decryptionKey);
// Counter will end up as NaN if this fails
view.counter = parseInt(counter);
@@ -82,8 +78,7 @@ export class Fido2Credential extends Domain {
this,
{ discoverable: "" },
["discoverable"],
orgId ?? null,
encKey,
decryptionKey,
);
view.discoverable = discoverable === "true";
view.creationDate = this.creationDate;

View File

@@ -6,7 +6,7 @@ import {
IdentityLinkedIdType,
} from "@bitwarden/sdk-internal";
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { CardLinkedId, IdentityLinkedId, LoginLinkedId } from "../../enums";
import { FieldData } from "../../models/data/field.data";
@@ -22,6 +22,7 @@ describe("Field", () => {
value: "encValue",
linkedId: null,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -33,14 +33,8 @@ export class Field extends Domain {
this.value = conditionalEncString(obj.value);
}
decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj<Field, FieldView>(
this,
new FieldView(this),
["name", "value"],
orgId ?? null,
encKey,
);
decrypt(encKey: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj<Field, FieldView>(this, new FieldView(this), ["name", "value"], encKey);
}
toFieldData(): FieldData {

View File

@@ -1,6 +1,12 @@
import { mock, MockProxy } from "jest-mock-extended";
import { makeEncString, makeSymmetricCryptoKey, mockEnc, mockFromJson } from "../../../../spec";
import {
makeEncString,
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { FolderData } from "../../models/data/folder.data";
@@ -15,6 +21,7 @@ describe("Folder", () => {
name: "encName",
revisionDate: "2022-01-31T12:00:00.000Z",
};
mockContainerService();
});
it("Convert", () => {
@@ -33,7 +40,7 @@ describe("Folder", () => {
folder.name = mockEnc("encName");
folder.revisionDate = new Date("2022-01-31T12:00:00.000Z");
const view = await folder.decrypt();
const view = await folder.decrypt(null);
expect(view).toEqual({
id: "id",

View File

@@ -39,8 +39,8 @@ export class Folder extends Domain {
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
}
decrypt(): Promise<FolderView> {
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
decrypt(key: SymmetricCryptoKey): Promise<FolderView> {
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], key);
}
async decryptWithKey(

View File

@@ -1,4 +1,4 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { IdentityData } from "../../models/data/identity.data";
import { Identity } from "../../models/domain/identity";
@@ -27,6 +27,8 @@ describe("Identity", () => {
passportNumber: "encpassportNumber",
licenseNumber: "enclicenseNumber",
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -56,9 +56,8 @@ export class Identity extends Domain {
}
decrypt(
orgId: string | undefined,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj<Identity, IdentityView>(
this,
@@ -83,7 +82,6 @@ export class Identity extends Domain {
"passportNumber",
"licenseNumber",
],
orgId ?? null,
encKey,
"DomainType: Identity; " + context,
);

View File

@@ -1,9 +1,14 @@
import { MockProxy, mock } from "jest-mock-extended";
import { MockProxy } from "jest-mock-extended";
import { Jsonify } from "type-fest";
import { UriMatchType } from "@bitwarden/sdk-internal";
import { mockEnc, mockFromJson } from "../../../../spec";
import {
makeSymmetricCryptoKey,
mockContainerService,
mockEnc,
mockFromJson,
} from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
@@ -14,6 +19,7 @@ import { LoginUri } from "./login-uri";
describe("LoginUri", () => {
let data: LoginUriData;
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
data = {
@@ -21,6 +27,9 @@ describe("LoginUri", () => {
uriChecksum: "encUriChecksum",
match: UriMatchStrategy.Domain,
};
const containerService = mockContainerService();
encryptService = containerService.getEncryptService();
});
it("Convert from empty", () => {
@@ -83,22 +92,13 @@ describe("LoginUri", () => {
});
describe("validateChecksum", () => {
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
encryptService = mock();
global.bitwardenContainerService = {
getEncryptService: () => encryptService,
getKeyService: () => null,
};
});
it("returns true if checksums match", async () => {
const loginUri = new LoginUri();
loginUri.uriChecksum = mockEnc("checksum");
encryptService.hash.mockResolvedValue("checksum");
const actual = await loginUri.validateChecksum("uri", undefined, undefined);
const key = makeSymmetricCryptoKey(64);
const actual = await loginUri.validateChecksum("uri", key);
expect(actual).toBe(true);
expect(encryptService.hash).toHaveBeenCalledWith("uri", "sha256");
@@ -109,7 +109,7 @@ describe("LoginUri", () => {
loginUri.uriChecksum = mockEnc("checksum");
encryptService.hash.mockResolvedValue("incorrect checksum");
const actual = await loginUri.validateChecksum("uri", undefined, undefined);
const actual = await loginUri.validateChecksum("uri", undefined);
expect(actual).toBe(false);
});

View File

@@ -31,29 +31,27 @@ export class LoginUri extends Domain {
}
decrypt(
orgId: string | undefined,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj<LoginUri, LoginUriView>(
this,
new LoginUriView(this),
["uri"],
orgId ?? null,
encKey,
context,
);
}
async validateChecksum(clearTextUri: string, orgId?: string, encKey?: SymmetricCryptoKey) {
async validateChecksum(clearTextUri: string, encKey: SymmetricCryptoKey) {
if (this.uriChecksum == null) {
return false;
}
const keyService = Utils.getContainerService().getEncryptService();
const localChecksum = await keyService.hash(clearTextUri, "sha256");
const encryptService = Utils.getContainerService().getEncryptService();
const localChecksum = await encryptService.hash(clearTextUri, "sha256");
const remoteChecksum = await this.uriChecksum.decrypt(orgId ?? null, encKey);
const remoteChecksum = await encryptService.decryptString(this.uriChecksum, encKey);
return remoteChecksum === localChecksum;
}

View File

@@ -1,6 +1,6 @@
import { MockProxy, mock } from "jest-mock-extended";
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../../models/domain/domain-service";
import { LoginData } from "../../models/data/login.data";
@@ -14,6 +14,10 @@ import { Fido2CredentialView } from "../view/fido2-credential.view";
import { Fido2Credential } from "./fido2-credential";
describe("Login DTO", () => {
beforeEach(() => {
mockContainerService();
});
it("Convert from empty LoginData", () => {
const data = new LoginData();
const login = new Login(data);
@@ -107,7 +111,7 @@ describe("Login DTO", () => {
loginUri.validateChecksum.mockResolvedValue(true);
login.uris = [loginUri];
const loginView = await login.decrypt(null, true);
const loginView = await login.decrypt(true, null);
expect(loginView).toEqual(expectedView);
});
@@ -119,7 +123,7 @@ describe("Login DTO", () => {
.mockResolvedValueOnce(true);
login.uris = [loginUri, loginUri, loginUri];
const loginView = await login.decrypt(null, false);
const loginView = await login.decrypt(false, null);
expect(loginView).toEqual(expectedView);
});
});

View File

@@ -44,16 +44,14 @@ export class Login extends Domain {
}
async decrypt(
orgId: string | undefined,
bypassValidation: boolean,
encKey: SymmetricCryptoKey,
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj<Login, LoginView>(
this,
new LoginView(this),
["username", "password", "totp"],
orgId ?? null,
encKey,
`DomainType: Login; ${context}`,
);
@@ -66,7 +64,7 @@ export class Login extends Domain {
continue;
}
const uri = await this.uris[i].decrypt(orgId, context, encKey);
const uri = await this.uris[i].decrypt(encKey, context);
const uriString = uri.uri;
if (uriString == null) {
@@ -79,7 +77,7 @@ export class Login extends Domain {
// So we bypass the validation if there's no cipher.key or proceed with the validation and
// Skip the value if it's been tampered with.
const isValidUri =
bypassValidation || (await this.uris[i].validateChecksum(uriString, orgId, encKey));
bypassValidation || (await this.uris[i].validateChecksum(uriString, encKey));
if (isValidUri) {
view.uris.push(uri);
@@ -89,7 +87,7 @@ export class Login extends Domain {
if (this.fido2Credentials != null) {
view.fido2Credentials = await Promise.all(
this.fido2Credentials.map((key) => key.decrypt(orgId, encKey)),
this.fido2Credentials.map((key) => key.decrypt(encKey)),
);
}

View File

@@ -1,4 +1,4 @@
import { mockEnc, mockFromJson } from "../../../../spec";
import { mockContainerService, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
import { PasswordHistoryData } from "../../models/data/password-history.data";
import { Password } from "../../models/domain/password";
@@ -11,6 +11,7 @@ describe("Password", () => {
password: "encPassword",
lastUsedDate: "2022-01-31T12:00:00.000Z",
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -22,12 +22,11 @@ export class Password extends Domain {
this.lastUsedDate = new Date(obj.lastUsedDate);
}
decrypt(orgId: string | undefined, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
decrypt(encKey: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj<Password, PasswordHistoryView>(
this,
new PasswordHistoryView(this),
["password"],
orgId ?? null,
encKey,
"DomainType: PasswordHistory",
);

View File

@@ -1,3 +1,4 @@
import { mockContainerService } from "../../../../spec";
import { SecureNoteType } from "../../enums";
import { SecureNoteData } from "../data/secure-note.data";
@@ -10,6 +11,8 @@ describe("SecureNote", () => {
data = {
type: SecureNoteType.Generic,
};
mockContainerService();
});
it("Convert from empty", () => {

View File

@@ -1,7 +1,7 @@
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal";
import { mockEnc } from "../../../../spec";
import { mockContainerService, mockEnc } from "../../../../spec";
import { SshKeyApi } from "../api/ssh-key.api";
import { SshKeyData } from "../data/ssh-key.data";
@@ -18,6 +18,8 @@ describe("Sshkey", () => {
KeyFingerprint: "keyFingerprint",
}),
);
mockContainerService();
});
it("Convert", () => {

View File

@@ -24,16 +24,11 @@ export class SshKey extends Domain {
this.keyFingerprint = new EncString(obj.keyFingerprint);
}
decrypt(
orgId: string | undefined,
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
decrypt(encKey: SymmetricCryptoKey, context = "No Cipher Context"): Promise<SshKeyView> {
return this.decryptObj<SshKey, SshKeyView>(
this,
new SshKeyView(),
["privateKey", "publicKey", "keyFingerprint"],
orgId ?? null,
encKey,
"DomainType: SshKey; " + context,
);

View File

@@ -55,7 +55,7 @@ const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "orgId",
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
folderId: "folderId",
edit: true,
viewPassword: true,
@@ -119,6 +119,8 @@ describe("Cipher Service", () => {
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
encryptService.encryptString.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
keyService.orgKeys$.mockReturnValue(of({ [orgId]: makeSymmetricCryptoKey(32) as OrgKey }));
keyService.userKey$.mockReturnValue(of(makeSymmetricCryptoKey(64) as UserKey));
// Mock i18nService collator
i18nService.collator = {
@@ -181,9 +183,6 @@ describe("Cipher Service", () => {
const testCipher = new Cipher(cipherData);
const expectedRevisionDate = "2022-01-31T12:00:00.000Z";
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
keyService.makeDataEncKey.mockReturnValue(
Promise.resolve([
new SymmetricCryptoKey(new Uint8Array(32)),
@@ -807,7 +806,7 @@ describe("Cipher Service", () => {
// Set up expected results
const expectedSuccessCipherViews = [
{ id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView,
{ id: mockCiphers[0].id, name: "Success 1", decryptionFailure: false } as CipherView,
];
const expectedFailedCipher = new CipherView(mockCiphers[1]);
@@ -815,6 +814,11 @@ describe("Cipher Service", () => {
expectedFailedCipher.decryptionFailure = true;
const expectedFailedCipherViews = [expectedFailedCipher];
cipherEncryptionService.decryptManyLegacy.mockResolvedValue([
expectedSuccessCipherViews,
expectedFailedCipherViews,
]);
// Execute
const [successes, failures] = await (cipherService as any).decryptCiphers(
mockCiphers,
@@ -822,10 +826,7 @@ describe("Cipher Service", () => {
);
// Verify the SDK was used for decryption
expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledWith(
mockCiphers,
userId,
);
expect(cipherEncryptionService.decryptManyLegacy).toHaveBeenCalledWith(mockCiphers, userId);
expect(successes).toEqual(expectedSuccessCipherViews);
expect(failures).toEqual(expectedFailedCipherViews);

View File

@@ -165,7 +165,9 @@ export class CipherService implements CipherServiceAbstraction {
}),
switchMap(async (ciphers) => {
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
await this.setFailedDecryptedCiphers(failures, userId);
void this.setFailedDecryptedCiphers(failures, userId);
// Trigger full decryption and indexing in background
void this.getAllDecrypted(userId);
return decrypted;
}),
tap((decrypted) => {
@@ -634,6 +636,15 @@ export class CipherService implements CipherServiceAbstraction {
);
}
async getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]> {
return firstValueFrom(
this.cipherViews$(userId).pipe(
filter((ciphers) => ciphers != null),
map((ciphers) => ciphers.filter((cipher) => ids.includes(cipher.id))),
),
);
}
async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: C[],
url: string,
@@ -1553,10 +1564,15 @@ export class CipherService implements CipherServiceAbstraction {
}
async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<UserKey | OrgKey> {
return (
(await this.keyService.getOrgKey(cipher.organizationId)) ||
((await this.keyService.getUserKey(userId)) as UserKey)
);
if (cipher.organizationId == null) {
return await firstValueFrom(this.keyService.userKey$(userId));
} else {
return await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(map((orgKeys) => orgKeys[cipher.organizationId as OrganizationId] as OrgKey)),
);
}
}
async setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId) {
@@ -1640,12 +1656,14 @@ export class CipherService implements CipherServiceAbstraction {
const key =
attachment.key != null
? attachment.key
: await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filterOutNullish(),
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
),
);
: cipherDomain.organizationId
? await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filterOutNullish(),
map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey),
),
)
: await firstValueFrom(this.keyService.userKey$(userId).pipe(filterOutNullish()));
return await this.encryptService.decryptFileData(encBuf, key);
}
@@ -1813,6 +1831,95 @@ export class CipherService implements CipherServiceAbstraction {
}
}
/**
* Upgrade all old attachments for a cipher by downloading, decrypting, re-uploading with new key, and deleting old.
* @param cipher
* @param userId
* @param attachmentId Optional specific attachment ID to upgrade. If not provided, all old attachments will be upgraded.
*/
async upgradeOldCipherAttachments(
cipher: CipherView,
userId: UserId,
attachmentId?: string,
): Promise<CipherView> {
if (!cipher.hasOldAttachments) {
return cipher;
}
let cipherDomain = await this.get(cipher.id, userId);
for (const attachmentView of cipher.attachments) {
if (
attachmentView.key != null ||
(attachmentId != null && attachmentView.id !== attachmentId)
) {
continue;
}
try {
// 1. Get download URL
const downloadUrl = await this.getAttachmentDownloadUrl(cipher.id, attachmentView);
// 2. Download attachment data
const dataResponse = await this.apiService.nativeFetch(
new Request(downloadUrl, { cache: "no-store" }),
);
if (dataResponse.status !== 200) {
throw new Error(`Failed to download attachment. Status: ${dataResponse.status}`);
}
// 3. Decrypt the attachment
const decryptedBuffer = await this.getDecryptedAttachmentBuffer(
cipher.id as CipherId,
attachmentView,
dataResponse,
userId,
);
// 4. Re-upload with attachment key
cipherDomain = await this.saveAttachmentRawWithServer(
cipherDomain,
attachmentView.fileName,
decryptedBuffer,
userId,
);
// 5. Delete the old attachment
const cipherData = await this.deleteAttachmentWithServer(
cipher.id,
attachmentView.id,
userId,
);
cipherDomain = new Cipher(cipherData);
} catch (e) {
this.logService.error(`Failed to upgrade attachment ${attachmentView.id}`, e);
throw e;
}
}
return await this.decrypt(cipherDomain, userId);
}
private async getAttachmentDownloadUrl(
cipherId: string,
attachmentView: AttachmentView,
): Promise<string> {
try {
const attachmentResponse = await this.apiService.getAttachmentData(
cipherId,
attachmentView.id,
);
return attachmentResponse.url;
} catch (e) {
// Fall back to the attachment's stored URL
if (e instanceof ErrorResponse && e.statusCode === 404 && attachmentView.url) {
return attachmentView.url;
}
throw new Error(`Failed to get download URL for attachment ${attachmentView.id}`);
}
}
private async encryptObjProperty<V extends View, D extends Domain>(
model: V,
obj: D,
@@ -2143,15 +2250,19 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
fullDecryption: boolean = true,
): Promise<[CipherViewLike[], CipherView[]]> {
if (fullDecryption) {
const [decryptedViews, failedViews] = await this.cipherEncryptionService.decryptManyLegacy(
ciphers,
userId,
);
return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews];
}
const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures(
ciphers,
userId,
);
const decryptedViews = fullDecryption
? await Promise.all(decrypted.map((c) => this.getFullCipherView(c)))
: decrypted;
const failedViews = failures.map((c) => {
const cipher_view = new CipherView(c);
cipher_view.name = "[error: cannot decrypt]";
@@ -2159,7 +2270,7 @@ export class CipherService implements CipherServiceAbstraction {
return cipher_view;
});
return [decryptedViews.sort(this.getLocaleSortingFunction()), failedViews];
return [decrypted.sort(this.getLocaleSortingFunction()), failedViews];
}
/** Fetches the full `CipherView` when a `CipherListView` is passed. */

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { of, firstValueFrom, BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
@@ -24,12 +24,14 @@ describe("DefaultCipherArchiveService", () => {
const userId = "user-id" as UserId;
const cipherId = "123" as CipherId;
const featureFlag = new BehaviorSubject<boolean>(true);
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockApiService = mock<ApiService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(featureFlag.asObservable());
service = new DefaultCipherArchiveService(
mockCipherService,
@@ -86,7 +88,7 @@ describe("DefaultCipherArchiveService", () => {
describe("userCanArchive$", () => {
it("should return true when user has premium and feature flag is enabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
featureFlag.next(true);
const result = await firstValueFrom(service.userCanArchive$(userId));
@@ -101,7 +103,7 @@ describe("DefaultCipherArchiveService", () => {
it("should return false when feature flag is disabled", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
featureFlag.next(false);
const result = await firstValueFrom(service.userCanArchive$(userId));
@@ -109,6 +111,93 @@ describe("DefaultCipherArchiveService", () => {
});
});
describe("hasArchiveFlagEnabled$", () => {
it("returns true when feature flag is enabled", async () => {
featureFlag.next(true);
const result = await firstValueFrom(service.hasArchiveFlagEnabled$);
expect(result).toBe(true);
expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM19148_InnovationArchive,
);
});
it("returns false when feature flag is disabled", async () => {
featureFlag.next(false);
const result = await firstValueFrom(service.hasArchiveFlagEnabled$);
expect(result).toBe(false);
});
});
describe("userHasPremium$", () => {
it("returns true when user has premium", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
const result = await firstValueFrom(service.userHasPremium$(userId));
expect(result).toBe(true);
expect(mockBillingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
userId,
);
});
it("returns false when user does not have premium", async () => {
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const result = await firstValueFrom(service.userHasPremium$(userId));
expect(result).toBe(false);
});
});
describe("showSubscriptionEndedMessaging$", () => {
it("returns true when user has archived ciphers but no premium", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
archivedDate: "2024-01-15T10:30:00.000Z",
type: "identity",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
expect(result).toBe(true);
});
it("returns false when user has archived ciphers and has premium", async () => {
const mockCiphers: CipherListView[] = [
{
id: "1",
archivedDate: "2024-01-15T10:30:00.000Z",
type: "identity",
} as unknown as CipherListView,
];
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
expect(result).toBe(false);
});
it("returns false when user has no archived ciphers and no premium", async () => {
mockCipherService.cipherListViews$.mockReturnValue(of([]));
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
expect(result).toBe(false);
});
});
describe("archiveWithServer", () => {
const mockResponse = {
data: [

View File

@@ -27,10 +27,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
private configService: ConfigService,
) {}
hasArchiveFlagEnabled$(): Observable<boolean> {
return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive);
}
/**
* Observable that contains the list of ciphers that have been archived.
*/
@@ -61,23 +57,22 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
);
}
/**
* User can access the archive vault if:
* Feature Flag is enabled
* There is at least one archived item
* ///////////// NOTE /////////////
* This is separated from userCanArchive because a user that loses premium status, but has archived items,
* should still be able to access their archive vault. The items will be read-only, and can be restored.
*/
showArchiveVault$(userId: UserId): Observable<boolean> {
return combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
this.archivedCiphers$(userId),
]).pipe(
map(
([archiveFlagEnabled, hasArchivedItems]) =>
archiveFlagEnabled && hasArchivedItems.length > 0,
),
/** Returns true when the archive features should be shown. */
hasArchiveFlagEnabled$: Observable<boolean> = this.configService
.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive)
.pipe(shareReplay({ refCount: true, bufferSize: 1 }));
/** Returns true when the user has premium from any means. */
userHasPremium$(userId: UserId): Observable<boolean> {
return this.billingAccountProfileStateService
.hasPremiumFromAnySource$(userId)
.pipe(shareReplay({ refCount: true, bufferSize: 1 }));
}
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}

View File

@@ -496,9 +496,11 @@ describe("DefaultCipherEncryptionService", () => {
.mockReturnValueOnce(expectedViews[0])
.mockReturnValueOnce(expectedViews[1]);
const result = await cipherEncryptionService.decryptManyLegacy(ciphers, userId);
const [successfulDecryptions, failedDecryptions] =
await cipherEncryptionService.decryptManyLegacy(ciphers, userId);
expect(result).toEqual(expectedViews);
expect(successfulDecryptions).toEqual(expectedViews);
expect(failedDecryptions).toEqual([]);
expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledTimes(2);
expect(CipherView.fromSdkCipherView).toHaveBeenCalledTimes(2);
});

View File

@@ -168,7 +168,7 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
);
}
decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<CipherView[]> {
decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise<[CipherView[], CipherView[]]> {
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map((sdk) => {
@@ -178,38 +178,49 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
using ref = sdk.take();
return ciphers.map((cipher) => {
const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher());
const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!;
const successful: CipherView[] = [];
const failed: CipherView[] = [];
// Handle FIDO2 credentials if present
if (
clientCipherView.type === CipherType.Login &&
sdkCipherView.login?.fido2Credentials?.length
) {
const fido2CredentialViews = ref.value
.vault()
.ciphers()
.decrypt_fido2_credentials(sdkCipherView);
ciphers.forEach((cipher) => {
try {
const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher());
const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!;
// TODO (PM-21259): Remove manual keyValue decryption for FIDO2 credentials.
// This is a temporary workaround until we can use the SDK for FIDO2 authentication.
const decryptedKeyValue = ref.value
.vault()
.ciphers()
.decrypt_fido2_private_key(sdkCipherView);
// Handle FIDO2 credentials if present
if (
clientCipherView.type === CipherType.Login &&
sdkCipherView.login?.fido2Credentials?.length
) {
const fido2CredentialViews = ref.value
.vault()
.ciphers()
.decrypt_fido2_credentials(sdkCipherView);
clientCipherView.login.fido2Credentials = fido2CredentialViews
.map((f) => {
const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!;
view.keyValue = decryptedKeyValue;
return view;
})
.filter((view): view is Fido2CredentialView => view !== undefined);
const decryptedKeyValue = ref.value
.vault()
.ciphers()
.decrypt_fido2_private_key(sdkCipherView);
clientCipherView.login.fido2Credentials = fido2CredentialViews
.map((f) => {
const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!;
view.keyValue = decryptedKeyValue;
return view;
})
.filter((view): view is Fido2CredentialView => view !== undefined);
}
successful.push(clientCipherView);
} catch (error) {
this.logService.error(`Failed to decrypt cipher ${cipher.id}: ${error}`);
const failedView = new CipherView(cipher);
failedView.name = "[error: cannot decrypt]";
failedView.decryptionFailure = true;
failed.push(failedView);
}
return clientCipherView;
});
return [successful, failed] as [CipherView[], CipherView[]];
}),
catchError((error: unknown) => {
this.logService.error(`Failed to decrypt ciphers: ${error}`);

View File

@@ -335,8 +335,10 @@ export class SearchService implements SearchServiceAbstraction {
if (
login &&
login.uris.length &&
login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1)
login.uris?.length &&
login.uris?.some(
(loginUri) => loginUri?.uri && loginUri.uri.toLowerCase().indexOf(query) > -1,
)
) {
return true;
}

View File

@@ -51,10 +51,10 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: false,
canUseAccessIntelligence: false,
},
{
useAccessIntelligence: true,
canUseAccessIntelligence: true,
},
] as Organization[]),
);
@@ -70,10 +70,10 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: false,
canUseAccessIntelligence: false,
},
{
useAccessIntelligence: false,
canUseAccessIntelligence: false,
},
] as Organization[]),
);
@@ -91,17 +91,17 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: true,
canUseAccessIntelligence: true,
},
] as Organization[]),
);
});
it("should return an empty array if tasks are not enabled", async () => {
it("should return no tasks if not present and canUserAccessIntelligence is false", async () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: false,
canUseAccessIntelligence: false,
},
] as Organization[]),
);
@@ -111,7 +111,6 @@ describe("Default task service", () => {
const result = await firstValueFrom(tasks$("user-id" as UserId));
expect(result.length).toBe(0);
expect(mockApiSend).not.toHaveBeenCalled();
});
it("should fetch tasks from the API when the state is null", async () => {
@@ -163,17 +162,17 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: true,
canUseAccessIntelligence: true,
},
] as Organization[]),
);
});
it("should return an empty array if tasks are not enabled", async () => {
it("should return no tasks if not present and canUserAccessIntelligence is false", async () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useAccessIntelligence: false,
canUseAccessIntelligence: false,
},
] as Organization[]),
);
@@ -183,7 +182,6 @@ describe("Default task service", () => {
const result = await firstValueFrom(pendingTasks$("user-id" as UserId));
expect(result.length).toBe(0);
expect(mockApiSend).not.toHaveBeenCalled();
});
it("should filter tasks to only pending tasks", async () => {

View File

@@ -48,7 +48,7 @@ export class DefaultTaskService implements TaskService {
tasksEnabled$ = perUserCache$((userId) => {
return this.organizationService.organizations$(userId).pipe(
map((orgs) => orgs.some((o) => o.useAccessIntelligence)),
map((orgs) => orgs.some((o) => o.canUseAccessIntelligence)),
distinctUntilChanged(),
);
});