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:
@@ -41,6 +41,18 @@ export function canAccessBillingTab(org: Organization): boolean {
|
||||
return org.isOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Intelligence is only available to:
|
||||
* - Enterprise organizations
|
||||
* - Users in those organizations with report access
|
||||
*
|
||||
* @param org The organization to verify access
|
||||
* @returns If true can access the Access Intelligence feature
|
||||
*/
|
||||
export function canAccessAccessIntelligence(org: Organization): boolean {
|
||||
return org.canUseAccessIntelligence && org.canAccessReports;
|
||||
}
|
||||
|
||||
export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
// Admin console can only be accessed by Owners for disabled organizations
|
||||
if (!org.enabled && !org.isOwner) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PolicyData {
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response?: PolicyResponse) {
|
||||
if (response == null) {
|
||||
@@ -22,6 +23,7 @@ export class PolicyData {
|
||||
this.type = response.type;
|
||||
this.data = response.data;
|
||||
this.enabled = response.enabled;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromPolicy(policy: Policy): PolicyData {
|
||||
|
||||
@@ -402,4 +402,8 @@ export class Organization {
|
||||
this.permissions.accessEventLogs)
|
||||
);
|
||||
}
|
||||
|
||||
get canUseAccessIntelligence() {
|
||||
return this.productTierType === ProductTierType.Enterprise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export class Policy extends Domain {
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj?: PolicyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
@@ -30,6 +32,7 @@ export class Policy extends Domain {
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
this.revisionDate = new Date(obj.revisionDate);
|
||||
}
|
||||
|
||||
static fromResponse(response: PolicyResponse): Policy {
|
||||
|
||||
@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
|
||||
data: any;
|
||||
enabled: boolean;
|
||||
canToggleState: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
|
||||
this.data = this.getResponseProperty("Data");
|
||||
this.enabled = this.getResponseProperty("Enabled");
|
||||
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,15 @@ describe("PolicyService", () => {
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
policyData.revisionDate = new Date().toISOString();
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export abstract class AccountService {
|
||||
abstract sortedUserIds$: Observable<UserId[]>;
|
||||
/** Next account that is not the current active account */
|
||||
abstract nextUpAccount$: Observable<Account>;
|
||||
/** Observable to display the header */
|
||||
abstract showHeader$: Observable<boolean>;
|
||||
/**
|
||||
* Updates the `accounts$` observable with the new account data.
|
||||
*
|
||||
@@ -100,6 +102,11 @@ export abstract class AccountService {
|
||||
* @param lastActivity
|
||||
*/
|
||||
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
|
||||
/**
|
||||
* Show the account switcher.
|
||||
* @param value
|
||||
*/
|
||||
abstract setShowHeader(visible: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalAccountService extends AccountService {
|
||||
|
||||
@@ -18,6 +18,8 @@ export class AuthResult {
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
// The master-password used in the authentication process
|
||||
masterPassword: string | null;
|
||||
|
||||
get requiresTwoFactor() {
|
||||
return this.twoFactorProviders != null;
|
||||
|
||||
@@ -26,7 +26,6 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
forcePasswordReset: boolean;
|
||||
masterPasswordPolicy: MasterPasswordPolicyResponse;
|
||||
apiUseKeyConnector: boolean;
|
||||
keyConnectorUrl: string;
|
||||
|
||||
userDecryptionOptions?: UserDecryptionOptionsResponse;
|
||||
|
||||
@@ -70,7 +69,7 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
|
||||
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
|
||||
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
|
||||
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
|
||||
|
||||
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
|
||||
this.getResponseProperty("MasterPasswordPolicy"),
|
||||
);
|
||||
|
||||
@@ -429,6 +429,16 @@ describe("accountService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("setShowHeader", () => {
|
||||
it("should update _showHeader$ when setShowHeader is called", async () => {
|
||||
expect(sut["_showHeader$"].value).toBe(true);
|
||||
|
||||
await sut.setShowHeader(false);
|
||||
|
||||
expect(sut["_showHeader$"].value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
distinctUntilChanged,
|
||||
shareReplay,
|
||||
combineLatest,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
switchMap,
|
||||
filter,
|
||||
@@ -84,6 +85,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
|
||||
export class AccountServiceImplementation implements InternalAccountService {
|
||||
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
|
||||
private activeAccountIdState: GlobalState<UserId | undefined>;
|
||||
private _showHeader$ = new BehaviorSubject<boolean>(true);
|
||||
|
||||
accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
activeAccount$: Observable<Account | null>;
|
||||
@@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
accountVerifyNewDeviceLogin$: Observable<boolean>;
|
||||
sortedUserIds$: Observable<UserId[]>;
|
||||
nextUpAccount$: Observable<Account>;
|
||||
showHeader$ = this._showHeader$.asObservable();
|
||||
|
||||
constructor(
|
||||
private messagingService: MessagingService,
|
||||
@@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
async setShowHeader(visible: boolean): Promise<void> {
|
||||
this._showHeader$.next(visible);
|
||||
}
|
||||
|
||||
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
|
||||
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
|
||||
return { ...oldAccountInfo, ...update };
|
||||
|
||||
@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockFamiliesPlan = {
|
||||
type: PlanType.FamiliesAnnually2025,
|
||||
@@ -250,7 +255,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
return "Custom";
|
||||
|
||||
// Plan descriptions
|
||||
case "planDescPremium":
|
||||
case "advancedOnlineSecurity":
|
||||
return "Premium plan description";
|
||||
case "planDescFamiliesV2":
|
||||
return "Families plan description";
|
||||
@@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupEnvironmentService = (
|
||||
envService: MockProxy<EnvironmentService>,
|
||||
region: Region = Region.US,
|
||||
) => {
|
||||
envService.environment$ = of({
|
||||
getRegion: () => region,
|
||||
isCloud: () => region !== Region.SelfHosted,
|
||||
} as any);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
billingApiService,
|
||||
configService,
|
||||
i18nService,
|
||||
logService,
|
||||
environmentService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -397,7 +415,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
|
||||
expect(i18nService.t).toHaveBeenCalledWith("premium");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planDescPremium");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("advancedOnlineSecurity");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator");
|
||||
@@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
|
||||
@@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
|
||||
@@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("Premium plan API error");
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan API response", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response missing the Seat property
|
||||
const malformedResponse = {
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle malformed premium plan with invalid price types", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
|
||||
|
||||
// Malformed response with price as string instead of number
|
||||
const malformedResponse = {
|
||||
Seat: {
|
||||
StripePriceId: "price_seat",
|
||||
Price: "10", // Should be a number
|
||||
},
|
||||
Storage: {
|
||||
StripePriceId: "price_storage",
|
||||
Price: 4,
|
||||
},
|
||||
};
|
||||
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
next: () => {
|
||||
fail("Observable should error, not return a value");
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"Failed to load personal subscription pricing tiers",
|
||||
testError,
|
||||
);
|
||||
expect(error).toEqual(testError);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Observable behavior and caching", () => {
|
||||
@@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to avoid conflicts with beforeEach setup
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||
|
||||
@@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
@@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to test from scratch
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||
@@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
storage: { price: 999 },
|
||||
} as PremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
// Create a new service instance with the feature flag disabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
@@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe with feature flag disabled
|
||||
@@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Self-hosted environment behavior", () => {
|
||||
it("should not call API for self-hosted environment", () => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
|
||||
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
// Trigger subscriptions by calling the methods
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
|
||||
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
|
||||
|
||||
// API should not be called for self-hosted environments
|
||||
expect(getPlansSpy).not.toHaveBeenCalled();
|
||||
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
|
||||
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const selfHostedConfigService = mock<ConfigService>();
|
||||
const selfHostedEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
|
||||
|
||||
const selfHostedService = new DefaultSubscriptionPricingService(
|
||||
selfHostedBillingApiService,
|
||||
selfHostedConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
selfHostedEnvironmentService,
|
||||
);
|
||||
|
||||
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
|
||||
expect(tiers).toHaveLength(2); // Premium and Families
|
||||
|
||||
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
|
||||
expect(premiumTier).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
|
||||
expect(premiumTier?.passwordManager.features).toBeDefined();
|
||||
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
@@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* Pricing information will be undefined if current environment is self-hosted.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
|
||||
: from(this.billingApiService.getPlans()),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
|
||||
this.billingApiService.getPremiumPlan(),
|
||||
).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
|
||||
: from(this.billingApiService.getPremiumPlan()).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
@@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
provided: premiumPlan.storage.provided,
|
||||
seat: premiumPlan.seat?.price,
|
||||
storage: premiumPlan.storage?.price,
|
||||
provided: premiumPlan.storage?.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
@@ -127,7 +145,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
map((premiumPrices) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("planDescPremium"),
|
||||
description: this.i18nService.t("advancedOnlineSecurity"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
@@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
)!;
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
);
|
||||
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan.PasswordManager.baseSeats,
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan?.PasswordManager?.baseSeats,
|
||||
annualPrice: familiesPlan?.PasswordManager?.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
@@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(
|
||||
freePlan?.PasswordManager?.maxCollections,
|
||||
),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
@@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
@@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
limitedUsersV2: (users?: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
limitedCollectionsV2: (collections?: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
@@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
projectsIncludedV2: (projects?: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
@@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
includedMachineAccountsV2: (included?: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
|
||||
@@ -27,26 +27,26 @@ type HasFeatures = {
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
annualPricePerAdditionalStorageGB?: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB: number;
|
||||
providedStorageGB?: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
users?: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
@@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerUser?: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
@@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
annualPricePerUser?: number;
|
||||
annualPricePerAdditionalServiceAccount?: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
|
||||
@@ -35,5 +35,26 @@ describe("HibpApiService", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
|
||||
});
|
||||
|
||||
it("should return empty array when no breaches found (REST semantics)", async () => {
|
||||
// Server now returns 200 OK with empty array [] instead of 404
|
||||
const mockResponse: any[] = [];
|
||||
const username = "safe@example.com";
|
||||
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sut.getHibpBreach(username);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/hibp/breach?username=" + encodeURIComponent(username),
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum FeatureFlag {
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
|
||||
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
|
||||
|
||||
/* Auth */
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
@@ -42,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
@@ -62,6 +64,8 @@ export enum FeatureFlag {
|
||||
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -96,6 +100,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
@@ -119,6 +124,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
@@ -143,6 +150,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
|
||||
@@ -91,7 +91,7 @@ export abstract class CryptoFunctionService {
|
||||
abstract rsaEncrypt(
|
||||
data: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
@@ -100,10 +100,10 @@ export abstract class CryptoFunctionService {
|
||||
abstract rsaDecrypt(
|
||||
data: Uint8Array,
|
||||
privateKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
|
||||
abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>;
|
||||
/**
|
||||
* Generates a key of the given length suitable for use in AES encryption
|
||||
*/
|
||||
|
||||
@@ -252,15 +252,9 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
|
||||
}
|
||||
|
||||
let algorithm: "sha1" | "sha256";
|
||||
switch (data.encryptionType) {
|
||||
case EncryptionType.Rsa2048_OaepSha1_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
|
||||
algorithm = "sha1";
|
||||
break;
|
||||
case EncryptionType.Rsa2048_OaepSha256_B64:
|
||||
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
|
||||
algorithm = "sha256";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid encryption type.");
|
||||
@@ -270,6 +264,6 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
|
||||
}
|
||||
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,6 @@ describe("WebCrypto Function Service", () => {
|
||||
});
|
||||
|
||||
describe("rsaGenerateKeyPair", () => {
|
||||
testRsaGenerateKeyPair(1024);
|
||||
testRsaGenerateKeyPair(2048);
|
||||
|
||||
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
|
||||
@@ -495,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
|
||||
function testRsaGenerateKeyPair(length: 2048) {
|
||||
it(
|
||||
"should successfully generate a " + length + " bit key pair",
|
||||
async () => {
|
||||
|
||||
@@ -263,33 +263,19 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
async rsaEncrypt(
|
||||
data: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
_algorithm: "sha1",
|
||||
): Promise<Uint8Array> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
|
||||
const buffer = await this.subtle.encrypt(rsaParams, impKey, data);
|
||||
return new Uint8Array(buffer);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.rsa_encrypt_data(data, publicKey);
|
||||
}
|
||||
|
||||
async rsaDecrypt(
|
||||
data: Uint8Array,
|
||||
privateKey: Uint8Array,
|
||||
algorithm: "sha1" | "sha256",
|
||||
_algorithm: "sha1",
|
||||
): Promise<Uint8Array> {
|
||||
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
|
||||
// We cannot use the proper types here.
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
|
||||
};
|
||||
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
|
||||
const buffer = await this.subtle.decrypt(rsaParams, impKey, data);
|
||||
return new Uint8Array(buffer);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.rsa_decrypt_data(data, privateKey);
|
||||
}
|
||||
|
||||
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
|
||||
@@ -297,6 +283,13 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey;
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> {
|
||||
await SdkLoadService.Ready;
|
||||
const privateKey = PureCrypto.rsa_generate_keypair();
|
||||
const publicKey = await this.rsaExtractPublicKey(privateKey);
|
||||
return [publicKey, privateKey];
|
||||
}
|
||||
|
||||
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
|
||||
if (bitLength === 512) {
|
||||
// 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys
|
||||
@@ -314,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return new Uint8Array(rawKey) as CsprngArray;
|
||||
}
|
||||
|
||||
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
|
||||
const rsaParams = {
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: length,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
|
||||
// Have to specify some algorithm
|
||||
hash: { name: this.toWebCryptoAlgorithm("sha1") },
|
||||
};
|
||||
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
|
||||
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
|
||||
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
|
||||
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];
|
||||
}
|
||||
|
||||
randomBytes(length: number): Promise<CsprngArray> {
|
||||
const arr = new Uint8Array(length);
|
||||
this.crypto.getRandomValues(arr);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { SyncService } from "../../platform/sync";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
|
||||
import { EncryptedMigration } from "./migrations/encrypted-migration";
|
||||
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
|
||||
|
||||
jest.mock("./migrations/minimum-kdf-migration");
|
||||
|
||||
describe("EncryptedMigrator", () => {
|
||||
const mockKdfConfigService = mock<KdfConfigService>();
|
||||
const mockChangeKdfService = mock<ChangeKdfService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const configService = mock<ConfigService>();
|
||||
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
|
||||
const syncService = mock<SyncService>();
|
||||
|
||||
let sut: DefaultEncryptedMigrator;
|
||||
const mockMigration = mock<MinimumKdfMigration>();
|
||||
|
||||
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||
const mockMasterPassword = "masterPassword123";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock the MinimumKdfMigration constructor to return our mock
|
||||
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
|
||||
() => mockMigration,
|
||||
);
|
||||
|
||||
sut = new DefaultEncryptedMigrator(
|
||||
mockKdfConfigService,
|
||||
mockChangeKdfService,
|
||||
mockLogService,
|
||||
configService,
|
||||
masterPasswordService,
|
||||
syncService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
await sut.runMigrations(mockUserId, null);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should run migration when needsMigration returns 'needsMigration'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
});
|
||||
|
||||
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
});
|
||||
|
||||
it("should throw error when migration needs master password but null is provided", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
await sut.runMigrations(mockUserId, null);
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should run multiple migrations", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
|
||||
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockMasterPassword,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsMigrations", () => {
|
||||
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigration' when at least one migration needs to run", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigration");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
|
||||
const mockSecondMigration = mock<EncryptedMigration>();
|
||||
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
|
||||
|
||||
(sut as any).migrations.push({
|
||||
name: "Test Second Migration",
|
||||
migration: mockSecondMigration,
|
||||
});
|
||||
|
||||
mockMigration.needsMigration.mockResolvedValue("needsMigration");
|
||||
|
||||
const result = await sut.needsMigrations(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigration");
|
||||
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { assertNonNullish } from "../../auth/utils";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { SyncService } from "../../platform/sync";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
|
||||
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
|
||||
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
|
||||
|
||||
export class DefaultEncryptedMigrator implements EncryptedMigrator {
|
||||
private migrations: { name: string; migration: EncryptedMigration }[] = [];
|
||||
private isRunningMigration = false;
|
||||
|
||||
constructor(
|
||||
readonly kdfConfigService: KdfConfigService,
|
||||
readonly changeKdfService: ChangeKdfService,
|
||||
private readonly logService: LogService,
|
||||
readonly configService: ConfigService,
|
||||
readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
readonly syncService: SyncService,
|
||||
) {
|
||||
// Register migrations here
|
||||
this.migrations.push({
|
||||
name: "Minimum PBKDF2 Iteration Count Migration",
|
||||
migration: new MinimumKdfMigration(
|
||||
kdfConfigService,
|
||||
changeKdfService,
|
||||
logService,
|
||||
configService,
|
||||
masterPasswordService,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
// Ensure that the requirements for running all migrations are met
|
||||
const needsMigration = await this.needsMigrations(userId);
|
||||
if (needsMigration === "noMigrationNeeded") {
|
||||
return;
|
||||
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
|
||||
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
|
||||
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
|
||||
// password, such as biometric unlock, the migrations are skipped.
|
||||
//
|
||||
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
|
||||
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// No concurrent migrations allowed, so acquire a service-wide lock
|
||||
if (this.isRunningMigration) {
|
||||
return;
|
||||
}
|
||||
this.isRunningMigration = true;
|
||||
|
||||
// Run all migrations sequentially in the order they were registered
|
||||
this.logService.mark("[Encrypted Migrator] Start");
|
||||
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
|
||||
let ranMigration = false;
|
||||
for (const { name, migration } of this.migrations) {
|
||||
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
|
||||
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
|
||||
const start = performance.now();
|
||||
await migration.runMigrations(userId, masterPassword);
|
||||
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
|
||||
ranMigration = true;
|
||||
}
|
||||
}
|
||||
this.logService.mark("[Encrypted Migrator] Finish");
|
||||
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
|
||||
if (ranMigration) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
|
||||
error,
|
||||
);
|
||||
throw error; // Re-throw the error to be handled by the caller
|
||||
} finally {
|
||||
this.isRunningMigration = false;
|
||||
}
|
||||
}
|
||||
|
||||
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const migrationRequirements = await Promise.all(
|
||||
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
|
||||
);
|
||||
|
||||
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
|
||||
return "needsMigrationWithMasterPassword";
|
||||
} else if (migrationRequirements.includes("needsMigration")) {
|
||||
return "needsMigration";
|
||||
} else {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
}
|
||||
|
||||
isRunningMigrations(): boolean {
|
||||
return this.isRunningMigration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { MigrationRequirement } from "./migrations/encrypted-migration";
|
||||
|
||||
export abstract class EncryptedMigrator {
|
||||
/**
|
||||
* Runs migrations on a decrypted user, with the cryptographic state initialized.
|
||||
* This only runs the migrations that are needed for the user.
|
||||
* This needs to be run after the decrypted user key has been set to state.
|
||||
*
|
||||
* If the master password is required but not provided, the migrations will not run, and the function will return early.
|
||||
* If migrations are already running, the migrations will not run again, and the function will return early.
|
||||
*
|
||||
* @param userId The ID of the user to run migrations for.
|
||||
* @param masterPassword The user's current master password.
|
||||
* @throws If the user does not exist
|
||||
* @throws If the user is locked or logged out
|
||||
* @throws If a migration fails
|
||||
*/
|
||||
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||
/**
|
||||
* Checks if the user needs to run any migrations.
|
||||
* This is used to determine if the user should be prompted to run migrations.
|
||||
* @param userId The ID of the user to check migrations for.
|
||||
*/
|
||||
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
|
||||
|
||||
/**
|
||||
* Indicates whether migrations are currently running.
|
||||
*/
|
||||
abstract isRunningMigrations(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UserId } from "../../../types/guid";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* IMPORTANT: Please read this when implementing new migrations.
|
||||
*
|
||||
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
|
||||
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
|
||||
* during actions such as login and unlock, or during sync.
|
||||
*
|
||||
* Migrations can require the master-password, which is provided by the user if required.
|
||||
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
|
||||
*
|
||||
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
|
||||
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
|
||||
* are made for the server, and other devices may run the migration concurrently.
|
||||
*
|
||||
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
|
||||
*/
|
||||
export interface EncryptedMigration {
|
||||
/**
|
||||
* Runs the migration.
|
||||
* @throws If the migration fails, such as when no network is available.
|
||||
* @throws If the requirements for migration are not met (e.g. the user is locked)
|
||||
*/
|
||||
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||
/**
|
||||
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
|
||||
*/
|
||||
needsMigration(userId: UserId): Promise<MigrationRequirement>;
|
||||
}
|
||||
|
||||
export type MigrationRequirement =
|
||||
| "needsMigration"
|
||||
| "needsMigrationWithMasterPassword"
|
||||
| "noMigrationNeeded";
|
||||
@@ -0,0 +1,184 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfType,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { MinimumKdfMigration } from "./minimum-kdf-migration";
|
||||
|
||||
describe("MinimumKdfMigration", () => {
|
||||
const mockKdfConfigService = mock<KdfConfigService>();
|
||||
const mockChangeKdfService = mock<ChangeKdfService>();
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
const mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
|
||||
|
||||
let sut: MinimumKdfMigration;
|
||||
|
||||
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
|
||||
const mockMasterPassword = "masterPassword";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
sut = new MinimumKdfMigration(
|
||||
mockKdfConfigService,
|
||||
mockChangeKdfService,
|
||||
mockLogService,
|
||||
mockConfigService,
|
||||
mockMasterPasswordService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("needsMigration", () => {
|
||||
it("should return 'noMigrationNeeded' when user does not have a master password`", async () => {
|
||||
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false);
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when user uses argon2id`", async () => {
|
||||
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true);
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4));
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should return 'noMigrationNeeded' when feature flag is disabled", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("noMigrationNeeded");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.ForceUpdateKDFSettings,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => {
|
||||
const mockKdfConfig = {
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
|
||||
};
|
||||
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.needsMigration(mockUserId);
|
||||
|
||||
expect(result).toBe("needsMigrationWithMasterPassword");
|
||||
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.ForceUpdateKDFSettings,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.needsMigration(null as any)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("should update KDF parameters with minimum PBKDF2 iterations", async () => {
|
||||
await sut.runMigrations(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
|
||||
);
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Verify the PBKDF2KdfConfig has the correct iteration count
|
||||
const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1];
|
||||
expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue);
|
||||
});
|
||||
|
||||
it("should throw error when userId is null", async () => {
|
||||
await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId");
|
||||
});
|
||||
|
||||
it("should throw error when userId is undefined", async () => {
|
||||
await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow(
|
||||
"userId",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when masterPassword is null", async () => {
|
||||
await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword");
|
||||
});
|
||||
|
||||
it("should throw error when masterPassword is undefined", async () => {
|
||||
await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow(
|
||||
"masterPassword",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors from changeKdfService", async () => {
|
||||
const mockError = new Error("KDF update failed");
|
||||
mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError);
|
||||
|
||||
await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow(
|
||||
"KDF update failed",
|
||||
);
|
||||
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
|
||||
);
|
||||
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { assertNonNullish } from "../../../auth/utils";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
|
||||
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
|
||||
import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* This migrator ensures the user's account has a minimum PBKDF2 iteration count.
|
||||
* It will update the entire account, logging out old clients if necessary.
|
||||
*/
|
||||
export class MinimumKdfMigration implements EncryptedMigration {
|
||||
constructor(
|
||||
private readonly kdfConfigService: KdfConfigService,
|
||||
private readonly changeKdfService: ChangeKdfService,
|
||||
private readonly logService: LogService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||
assertNonNullish(userId, "userId");
|
||||
assertNonNullish(masterPassword, "masterPassword");
|
||||
|
||||
this.logService.info(
|
||||
`[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`,
|
||||
);
|
||||
await this.changeKdfService.updateUserKdfParams(
|
||||
masterPassword!,
|
||||
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
|
||||
userId,
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(
|
||||
userId,
|
||||
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
|
||||
);
|
||||
}
|
||||
|
||||
async needsMigration(userId: UserId): Promise<MigrationRequirement> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
if (!(await this.masterPasswordService.userHasMasterPassword(userId))) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
// Only PBKDF2 users below the minimum iteration count need migration
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
if (
|
||||
kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 ||
|
||||
kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min
|
||||
) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) {
|
||||
return "noMigrationNeeded";
|
||||
}
|
||||
|
||||
return "needsMigrationWithMasterPassword";
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "./change-kdf-service";
|
||||
import { DefaultChangeKdfService } from "./change-kdf.service";
|
||||
|
||||
describe("ChangeKdfService", () => {
|
||||
const changeKdfApiService = mock<ChangeKdfApiService>();
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||
import { ChangeKdfService } from "./change-kdf-service.abstraction";
|
||||
import { ChangeKdfService } from "./change-kdf.service.abstraction";
|
||||
|
||||
export class DefaultChangeKdfService implements ChangeKdfService {
|
||||
constructor(
|
||||
@@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction {
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
) => Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* Returns whether the user has a master password set.
|
||||
* @param userId The user ID.
|
||||
* @throws If the user ID is missing.
|
||||
*/
|
||||
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {
|
||||
|
||||
@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
this.masterKeyHashSubject.next(initialMasterKeyHash);
|
||||
}
|
||||
|
||||
userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||
return this.mock.userHasMasterPassword(userId);
|
||||
}
|
||||
|
||||
emailToSalt(email: string): MasterPasswordSalt {
|
||||
return this.mock.emailToSalt(email);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
|
||||
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
@@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||
assertNonNullish(userId, "userId");
|
||||
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
|
||||
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
|
||||
const usesKeyConnector = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
|
||||
);
|
||||
const usesMasterKey = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
|
||||
);
|
||||
return usesMasterKey && !usesKeyConnector;
|
||||
}
|
||||
|
||||
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
|
||||
assertNonNullish(userId, "userId");
|
||||
return this.accountService.accounts$.pipe(
|
||||
@@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
masterPasswordUnlockData.kdf.toSdkConfig(),
|
||||
),
|
||||
);
|
||||
|
||||
return userKey as UserKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { VaultTimeout } from "../../vault-timeout";
|
||||
|
||||
export abstract class SessionTimeoutTypeService {
|
||||
/**
|
||||
* Is provided timeout type available on this client type, OS ?
|
||||
* @param timeout the timeout type
|
||||
*/
|
||||
abstract isAvailable(timeout: VaultTimeout): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns the highest available and permissive timeout type, that is higher than or equals the provided timeout type.
|
||||
* @param timeout the provided timeout type
|
||||
*/
|
||||
abstract getOrPromoteToAvailable(timeout: VaultTimeout): Promise<VaultTimeout>;
|
||||
}
|
||||
3
libs/common/src/key-management/session-timeout/index.ts
Normal file
3
libs/common/src/key-management/session-timeout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SessionTimeoutTypeService } from "./abstractions/session-timeout-type.service";
|
||||
export { MaximumSessionTimeoutPolicyData } from "./types/maximum-session-timeout-policy.type";
|
||||
export { SessionTimeoutAction, SessionTimeoutType } from "./types/session-timeout.type";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type";
|
||||
|
||||
export interface MaximumSessionTimeoutPolicyData {
|
||||
type?: SessionTimeoutType;
|
||||
minutes: number;
|
||||
action?: SessionTimeoutAction;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SessionTimeoutAction = null | "lock" | "logOut";
|
||||
export type SessionTimeoutType =
|
||||
| null
|
||||
| "never"
|
||||
| "onAppRestart"
|
||||
| "onSystemLock"
|
||||
| "immediately"
|
||||
| "custom";
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
EncString,
|
||||
SignedSecurityState as SdkSignedSecurityState,
|
||||
SignedPublicKey as SdkSignedPublicKey,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
/**
|
||||
* A private key, encrypted with a symmetric key.
|
||||
@@ -10,7 +14,7 @@ export type WrappedPrivateKey = Opaque<EncString, "WrappedPrivateKey">;
|
||||
/**
|
||||
* A public key, signed with the accounts signature key.
|
||||
*/
|
||||
export type SignedPublicKey = Opaque<string, "SignedPublicKey">;
|
||||
export type SignedPublicKey = Opaque<SdkSignedPublicKey, "SignedPublicKey">;
|
||||
/**
|
||||
* A public key in base64 encoded SPKI-DER
|
||||
*/
|
||||
|
||||
@@ -4,8 +4,9 @@ export { VaultTimeoutService } from "./abstractions/vault-timeout.service";
|
||||
export { VaultTimeoutService as DefaultVaultTimeoutService } from "./services/vault-timeout.service";
|
||||
export { VaultTimeoutAction } from "./enums/vault-timeout-action.enum";
|
||||
export {
|
||||
isVaultTimeoutTypeNumeric,
|
||||
VaultTimeout,
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutNumberType,
|
||||
VaultTimeoutStringType,
|
||||
} from "./types/vault-timeout.type";
|
||||
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";
|
||||
|
||||
@@ -21,9 +21,14 @@ import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
|
||||
import { SessionTimeoutTypeService } from "../../session-timeout";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutNumberType,
|
||||
VaultTimeoutStringType,
|
||||
} from "../types/vault-timeout.type";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
|
||||
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
|
||||
@@ -40,9 +45,11 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
|
||||
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let logService: MockProxy<LogService>;
|
||||
let sessionTimeoutTypeService: MockProxy<SessionTimeoutTypeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
@@ -67,8 +74,8 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
logService = mock<LogService>();
|
||||
sessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
|
||||
|
||||
const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout
|
||||
vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout);
|
||||
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
@@ -259,40 +266,276 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// policy, vaultTimeout, expected
|
||||
[null, null, 15], // no policy, no vault timeout, falls back to default
|
||||
[30, 90, 30], // policy overrides vault timeout
|
||||
[30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range
|
||||
[90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never"
|
||||
[null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout
|
||||
[90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate)
|
||||
[null, 0, 0], // no policy, persist 0 (immediate) vault timeout
|
||||
[90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart"
|
||||
[null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout
|
||||
[90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked"
|
||||
[null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout
|
||||
[90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep"
|
||||
[null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout
|
||||
[90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle"
|
||||
[null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout
|
||||
])(
|
||||
"when policy is %s, and vault timeout is %s, returns %s",
|
||||
async (policy, vaultTimeout, expected) => {
|
||||
describe("no policy", () => {
|
||||
it("when vault timeout is null, returns default", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, null, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(defaultVaultTimeout);
|
||||
});
|
||||
|
||||
it.each([
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
])("when vault timeout is %s, returns unchanged", async (vaultTimeout) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
expect(result).toBe(vaultTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: custom", () => {
|
||||
const policyMinutes = 30;
|
||||
|
||||
it.each([
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
])(
|
||||
"when vault timeout is %s and exceeds policy max, returns policy minutes",
|
||||
async (vaultTimeout) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(policyMinutes);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([VaultTimeoutNumberType.OnMinute, policyMinutes])(
|
||||
"when vault timeout is %s and within policy max, returns unchanged",
|
||||
async (vaultTimeout) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(vaultTimeout);
|
||||
},
|
||||
);
|
||||
|
||||
it("when vault timeout is Immediately, returns Immediately", async () => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(
|
||||
VAULT_TIMEOUT,
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(result).toBe(VaultTimeoutNumberType.Immediately);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: immediately", () => {
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
])(
|
||||
"when current timeout is %s, returns immediately or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutNumberType.Immediately;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
expect(result).toBe(expectedTimeout);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("policy type: onSystemLock", () => {
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
])(
|
||||
"when current timeout is %s, returns onLocked or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutStringType.OnLocked;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
expect(result).toBe(expectedTimeout);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
])("when current timeout is numeric %s, returns unchanged", async (currentTimeout) => {
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: onAppRestart", () => {
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
])("when current timeout is %s, returns onRestart", async (currentTimeout) => {
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(result).toBe(VaultTimeoutStringType.OnRestart);
|
||||
});
|
||||
|
||||
it.each([
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: never", () => {
|
||||
it("when current timeout is never, returns never or promoted value", async () => {
|
||||
const expectedTimeout = VaultTimeoutStringType.Never;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.Never,
|
||||
);
|
||||
expect(result).toBe(expectedTimeout);
|
||||
});
|
||||
|
||||
it.each([
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
VaultTimeoutStringType.OnSleep,
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
VaultTimeoutNumberType.OnMinute,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
])("when current timeout is %s, returns unchanged", async (currentTimeout) => {
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setVaultTimeoutOptions", () => {
|
||||
@@ -405,6 +648,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
stateProvider,
|
||||
logService,
|
||||
defaultVaultTimeout,
|
||||
sessionTimeoutTypeService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
catchError,
|
||||
combineLatest,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -23,7 +24,6 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
@@ -31,9 +31,15 @@ import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction";
|
||||
import { MaximumSessionTimeoutPolicyData, SessionTimeoutTypeService } from "../../session-timeout";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
|
||||
import {
|
||||
isVaultTimeoutTypeNumeric,
|
||||
VaultTimeout,
|
||||
VaultTimeoutNumberType,
|
||||
VaultTimeoutStringType,
|
||||
} from "../types/vault-timeout.type";
|
||||
|
||||
import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state";
|
||||
|
||||
@@ -49,6 +55,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private defaultVaultTimeout: VaultTimeout,
|
||||
private sessionTimeoutTypeService: SessionTimeoutTypeService,
|
||||
) {}
|
||||
|
||||
async setVaultTimeoutOptions(
|
||||
@@ -131,11 +138,25 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT, userId),
|
||||
this.getMaxVaultTimeoutPolicyByUserId$(userId),
|
||||
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => {
|
||||
return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe(
|
||||
switchMap(([currentVaultTimeout, maxSessionTimeoutPolicyData]) => {
|
||||
this.logService.debug(
|
||||
"[VaultTimeoutSettingsService] Current vault timeout is %o for user id %s, max session policy %o",
|
||||
currentVaultTimeout,
|
||||
userId,
|
||||
maxSessionTimeoutPolicyData,
|
||||
);
|
||||
return from(
|
||||
this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData),
|
||||
).pipe(
|
||||
tap((vaultTimeout: VaultTimeout) => {
|
||||
this.logService.debug(
|
||||
"[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s",
|
||||
vaultTimeout,
|
||||
userId,
|
||||
);
|
||||
|
||||
// As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current
|
||||
if (vaultTimeout !== currentVaultTimeout) {
|
||||
return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId);
|
||||
@@ -155,28 +176,63 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
private async determineVaultTimeout(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxVaultTimeoutPolicy: Policy | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeout | null> {
|
||||
// if current vault timeout is null, apply the client specific default
|
||||
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
|
||||
|
||||
// If no policy applies, return the current vault timeout
|
||||
if (!maxVaultTimeoutPolicy) {
|
||||
if (maxSessionTimeoutPolicyData == null) {
|
||||
return currentVaultTimeout;
|
||||
}
|
||||
|
||||
// User is subject to a max vault timeout policy
|
||||
const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data;
|
||||
|
||||
// If the current vault timeout is not numeric, change it to the policy compliant value
|
||||
if (typeof currentVaultTimeout === "string") {
|
||||
return maxVaultTimeoutPolicyData.minutes;
|
||||
switch (maxSessionTimeoutPolicyData.type) {
|
||||
case "immediately":
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
case "custom":
|
||||
case null:
|
||||
case undefined:
|
||||
if (currentVaultTimeout === VaultTimeoutNumberType.Immediately) {
|
||||
return currentVaultTimeout;
|
||||
}
|
||||
if (isVaultTimeoutTypeNumeric(currentVaultTimeout)) {
|
||||
return Math.min(currentVaultTimeout as number, maxSessionTimeoutPolicyData.minutes);
|
||||
}
|
||||
return maxSessionTimeoutPolicyData.minutes;
|
||||
case "onSystemLock":
|
||||
if (
|
||||
currentVaultTimeout === VaultTimeoutStringType.Never ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnRestart ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
|
||||
) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "onAppRestart":
|
||||
if (
|
||||
currentVaultTimeout === VaultTimeoutStringType.Never ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnLocked ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
|
||||
) {
|
||||
return VaultTimeoutStringType.OnRestart;
|
||||
}
|
||||
break;
|
||||
case "never":
|
||||
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.Never,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy
|
||||
const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes);
|
||||
|
||||
return policyCompliantTimeout;
|
||||
return currentVaultTimeout;
|
||||
}
|
||||
|
||||
private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise<void> {
|
||||
@@ -198,14 +254,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
return combineLatest([
|
||||
this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId),
|
||||
this.getMaxVaultTimeoutPolicyByUserId$(userId),
|
||||
this.getMaxSessionTimeoutPolicyDataByUserId$(userId),
|
||||
]).pipe(
|
||||
switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => {
|
||||
switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => {
|
||||
return from(
|
||||
this.determineVaultTimeoutAction(
|
||||
userId,
|
||||
currentVaultTimeoutAction,
|
||||
maxVaultTimeoutPolicy,
|
||||
maxSessionTimeoutPolicyData,
|
||||
),
|
||||
).pipe(
|
||||
tap((vaultTimeoutAction: VaultTimeoutAction) => {
|
||||
@@ -235,7 +291,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
private async determineVaultTimeoutAction(
|
||||
userId: string,
|
||||
currentVaultTimeoutAction: VaultTimeoutAction | null,
|
||||
maxVaultTimeoutPolicy: Policy | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeoutAction> {
|
||||
const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId);
|
||||
if (availableVaultTimeoutActions.length === 1) {
|
||||
@@ -243,11 +299,13 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
|
||||
if (
|
||||
maxVaultTimeoutPolicy?.data?.action &&
|
||||
availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action)
|
||||
maxSessionTimeoutPolicyData?.action &&
|
||||
availableVaultTimeoutActions.includes(
|
||||
maxSessionTimeoutPolicyData.action as VaultTimeoutAction,
|
||||
)
|
||||
) {
|
||||
// return policy defined vault timeout action
|
||||
return maxVaultTimeoutPolicy.data.action;
|
||||
// return policy defined session timeout action
|
||||
return maxSessionTimeoutPolicyData.action as VaultTimeoutAction;
|
||||
}
|
||||
|
||||
// No policy applies from here on
|
||||
@@ -262,14 +320,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
return currentVaultTimeoutAction;
|
||||
}
|
||||
|
||||
private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable<Policy | null> {
|
||||
private getMaxSessionTimeoutPolicyDataByUserId$(
|
||||
userId: UserId,
|
||||
): Observable<MaximumSessionTimeoutPolicyData | null> {
|
||||
if (!userId) {
|
||||
throw new Error("User id required. Cannot get max vault timeout policy.");
|
||||
throw new Error("User id required. Cannot get max session timeout policy.");
|
||||
}
|
||||
|
||||
return this.policyService
|
||||
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(getFirstPolicy);
|
||||
return this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId).pipe(
|
||||
getFirstPolicy,
|
||||
map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null),
|
||||
);
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
|
||||
export interface MaximumVaultTimeoutPolicyData {
|
||||
minutes: number;
|
||||
action?: VaultTimeoutAction;
|
||||
}
|
||||
@@ -5,13 +5,25 @@ export const VaultTimeoutStringType = {
|
||||
OnLocked: "onLocked", // -2
|
||||
OnSleep: "onSleep", // -3
|
||||
OnIdle: "onIdle", // -4
|
||||
Custom: "custom", // -100
|
||||
} as const;
|
||||
|
||||
export const VaultTimeoutNumberType = {
|
||||
Immediately: 0,
|
||||
OnMinute: 1,
|
||||
EightHours: 480,
|
||||
} as const;
|
||||
|
||||
export type VaultTimeout =
|
||||
| number // 0 or positive numbers only
|
||||
| (typeof VaultTimeoutNumberType)[keyof typeof VaultTimeoutNumberType]
|
||||
| number // 0 or positive numbers (in minutes). See VaultTimeoutNumberType for common numeric presets
|
||||
| (typeof VaultTimeoutStringType)[keyof typeof VaultTimeoutStringType];
|
||||
|
||||
export interface VaultTimeoutOption {
|
||||
name: string;
|
||||
value: VaultTimeout;
|
||||
}
|
||||
|
||||
export function isVaultTimeoutTypeNumeric(timeout: VaultTimeout): boolean {
|
||||
return typeof timeout === "number";
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface Fido2AuthenticatorGetAssertionParams {
|
||||
rpId: string;
|
||||
/** The hash of the serialized client data, provided by the client. */
|
||||
hash: BufferSource;
|
||||
allowCredentialDescriptorList: PublicKeyCredentialDescriptor[];
|
||||
allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[];
|
||||
/** The effective user verification requirement for assertion, a Boolean value provided by the client. */
|
||||
requireUserVerification: boolean;
|
||||
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */
|
||||
|
||||
@@ -95,7 +95,7 @@ export abstract class Fido2UserInterfaceSession {
|
||||
*/
|
||||
abstract confirmNewCredential(
|
||||
params: NewCredentialParams,
|
||||
): Promise<{ cipherId: string; userVerified: boolean }>;
|
||||
): Promise<{ cipherId?: string; userVerified: boolean }>;
|
||||
|
||||
/**
|
||||
* Make sure that the vault is unlocked.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { init_sdk } from "@bitwarden/sdk-internal";
|
||||
import { init_sdk, LogLevel } from "@bitwarden/sdk-internal";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs
|
||||
import type { SdkService } from "./sdk.service";
|
||||
@@ -10,6 +10,7 @@ export class SdkLoadFailedError extends Error {
|
||||
}
|
||||
|
||||
export abstract class SdkLoadService {
|
||||
protected static logLevel: LogLevel = LogLevel.Info;
|
||||
private static markAsReady: () => void;
|
||||
private static markAsFailed: (error: unknown) => void;
|
||||
|
||||
@@ -41,7 +42,7 @@ export abstract class SdkLoadService {
|
||||
async loadAndInit(): Promise<void> {
|
||||
try {
|
||||
await this.load();
|
||||
init_sdk();
|
||||
init_sdk(SdkLoadService.logLevel);
|
||||
SdkLoadService.markAsReady();
|
||||
} catch (error) {
|
||||
SdkLoadService.markAsFailed(error);
|
||||
|
||||
@@ -73,14 +73,13 @@ export default class Domain {
|
||||
domain: DomainEncryptableKeys<D>,
|
||||
viewModel: ViewEncryptableKeys<V>,
|
||||
props: EncryptableKeys<D, V>[],
|
||||
orgId: string | null,
|
||||
key: SymmetricCryptoKey | null = null,
|
||||
objectContext: string = "No Domain Context",
|
||||
): Promise<V> {
|
||||
for (const prop of props) {
|
||||
viewModel[prop] =
|
||||
(await domain[prop]?.decrypt(
|
||||
orgId,
|
||||
null,
|
||||
key,
|
||||
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
|
||||
)) ?? null;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
|
||||
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
|
||||
describe("Fido2 Utils", () => {
|
||||
@@ -67,4 +73,62 @@ describe("Fido2 Utils", () => {
|
||||
expect(expectedArray).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cipherHasNoOtherPasskeys(...)", () => {
|
||||
const emptyPasskeyCipher = mock<CipherView>({
|
||||
id: "id-5",
|
||||
localData: { lastUsedDate: 222 },
|
||||
name: "name-5",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "username-5",
|
||||
password: "password",
|
||||
uri: "https://example.com",
|
||||
fido2Credentials: [],
|
||||
},
|
||||
});
|
||||
|
||||
const passkeyCipher = mock<CipherView>({
|
||||
id: "id-5",
|
||||
localData: { lastUsedDate: 222 },
|
||||
name: "name-5",
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "username-5",
|
||||
password: "password",
|
||||
uri: "https://example.com",
|
||||
fido2Credentials: [
|
||||
mock<Fido2CredentialView>({
|
||||
credentialId: "credential-id",
|
||||
rpName: "credential-name",
|
||||
userHandle: "user-handle-1",
|
||||
userName: "credential-username",
|
||||
rpId: "jest-testing-website.com",
|
||||
}),
|
||||
mock<Fido2CredentialView>({
|
||||
credentialId: "credential-id",
|
||||
rpName: "credential-name",
|
||||
userHandle: "user-handle-2",
|
||||
userName: "credential-username",
|
||||
rpId: "jest-testing-website.com",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it("should return true when there is no userHandle", () => {
|
||||
const userHandle = "user-handle-1";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return true when userHandle matches", () => {
|
||||
const userHandle = "user-handle-1";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false when userHandle doesn't match", () => {
|
||||
const userHandle = "testing";
|
||||
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
import type {
|
||||
AssertCredentialResult,
|
||||
@@ -111,4 +113,16 @@ export class Fido2Utils {
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
|
||||
* @param userHandle
|
||||
*/
|
||||
static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
|
||||
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
import { guidToRawFormat } from "./guid-utils";
|
||||
import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";
|
||||
|
||||
const workingExamples: [string, Uint8Array][] = [
|
||||
[
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
]),
|
||||
],
|
||||
[
|
||||
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
describe("guid-utils", () => {
|
||||
describe("guidToRawFormat", () => {
|
||||
it.each(workingExamples)(
|
||||
"returns UUID in binary format when given a valid UUID string",
|
||||
(input, expected) => {
|
||||
const result = guidToRawFormat(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
[
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
],
|
||||
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
|
||||
[
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7,
|
||||
],
|
||||
],
|
||||
])("returns UUID in binary format when given a valid UUID string", (input, expected) => {
|
||||
const result = guidToRawFormat(input);
|
||||
|
||||
expect(result).toEqual(new Uint8Array(expected));
|
||||
"invalid",
|
||||
"",
|
||||
"",
|
||||
"00000000-0000-0000-0000-0000000000000000",
|
||||
"00000000-0000-0000-0000-000000",
|
||||
])("throws an error when given an invalid UUID string", (input) => {
|
||||
expect(() => guidToRawFormat(input)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when given an invalid UUID string", () => {
|
||||
expect(() => guidToRawFormat("invalid")).toThrow(TypeError);
|
||||
describe("guidToStandardFormat", () => {
|
||||
it.each(workingExamples)(
|
||||
"returns UUID in standard format when given a valid UUID array buffer",
|
||||
(expected, input) => {
|
||||
const result = guidToStandardFormat(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
new Uint8Array(),
|
||||
new Uint8Array([]),
|
||||
new Uint8Array([
|
||||
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
|
||||
0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7,
|
||||
]),
|
||||
])("throws an error when given an invalid UUID array buffer", (input) => {
|
||||
expect(() => guidToStandardFormat(input)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) {
|
||||
|
||||
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
|
||||
export function guidToStandardFormat(bufferSource: BufferSource) {
|
||||
if (bufferSource.byteLength !== 16) {
|
||||
throw TypeError("BufferSource length is invalid");
|
||||
}
|
||||
|
||||
const arr =
|
||||
bufferSource instanceof ArrayBuffer
|
||||
? new Uint8Array(bufferSource)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { WrappedSigningKey } from "../../../key-management/types";
|
||||
import { SignedPublicKey, WrappedSigningKey } from "../../../key-management/types";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
|
||||
import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state";
|
||||
@@ -35,3 +35,12 @@ export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition<WrappedSigni
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export const USER_SIGNED_PUBLIC_KEY = new UserKeyDefinition<SignedPublicKey>(
|
||||
CRYPTO_DISK,
|
||||
"userSignedPublicKey",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("DefaultSdkService", () => {
|
||||
.mockReturnValue(of("private-key" as EncryptedString));
|
||||
keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({}));
|
||||
keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null));
|
||||
keyService.userSignedPublicKey$.calledWith(userId).mockReturnValue(of(null));
|
||||
securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null));
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
@@ -24,15 +25,14 @@ import {
|
||||
ClientSettings,
|
||||
TokenProvider,
|
||||
UnsignedSharedKey,
|
||||
WrappedAccountCryptographicState,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service";
|
||||
import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types";
|
||||
import { OrganizationId, UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
@@ -174,6 +174,9 @@ export class DefaultSdkService implements SdkService {
|
||||
const securityState$ = this.securityStateService
|
||||
.accountSecurityState$(userId)
|
||||
.pipe(distinctUntilChanged(compareValues));
|
||||
const signedPublicKey$ = this.keyService
|
||||
.userSignedPublicKey$(userId)
|
||||
.pipe(distinctUntilChanged(compareValues));
|
||||
|
||||
const client$ = combineLatest([
|
||||
this.environmentService.getEnvironment$(userId),
|
||||
@@ -184,11 +187,22 @@ export class DefaultSdkService implements SdkService {
|
||||
signingKey$,
|
||||
orgKeys$,
|
||||
securityState$,
|
||||
signedPublicKey$,
|
||||
SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded
|
||||
]).pipe(
|
||||
// switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value.
|
||||
switchMap(
|
||||
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
|
||||
([
|
||||
env,
|
||||
account,
|
||||
kdfParams,
|
||||
privateKey,
|
||||
userKey,
|
||||
signingKey,
|
||||
orgKeys,
|
||||
securityState,
|
||||
signedPublicKey,
|
||||
]) => {
|
||||
// Create our own observable to be able to implement clean-up logic
|
||||
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
|
||||
const createAndInitializeClient = async () => {
|
||||
@@ -202,15 +216,31 @@ export class DefaultSdkService implements SdkService {
|
||||
settings,
|
||||
);
|
||||
|
||||
let accountCryptographicState: WrappedAccountCryptographicState;
|
||||
if (signingKey != null && securityState != null && signedPublicKey != null) {
|
||||
accountCryptographicState = {
|
||||
V2: {
|
||||
private_key: privateKey,
|
||||
signing_key: signingKey,
|
||||
security_state: securityState,
|
||||
signed_public_key: signedPublicKey,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
accountCryptographicState = {
|
||||
V1: {
|
||||
private_key: privateKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await this.initializeClient(
|
||||
userId,
|
||||
client,
|
||||
account,
|
||||
kdfParams,
|
||||
privateKey,
|
||||
userKey,
|
||||
signingKey,
|
||||
securityState,
|
||||
accountCryptographicState,
|
||||
orgKeys,
|
||||
);
|
||||
|
||||
@@ -245,10 +275,8 @@ export class DefaultSdkService implements SdkService {
|
||||
client: PasswordManagerClient,
|
||||
account: AccountInfo,
|
||||
kdfParams: KdfConfig,
|
||||
privateKey: EncryptedString,
|
||||
userKey: UserKey,
|
||||
signingKey: WrappedSigningKey | null,
|
||||
securityState: SignedSecurityState | null,
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
orgKeys: Record<OrganizationId, EncString>,
|
||||
) {
|
||||
await client.crypto().initialize_user_crypto({
|
||||
@@ -265,9 +293,7 @@ export class DefaultSdkService implements SdkService {
|
||||
parallelism: kdfParams.parallelism,
|
||||
},
|
||||
},
|
||||
privateKey,
|
||||
signingKey: signingKey || undefined,
|
||||
securityState: securityState || undefined,
|
||||
accountCryptographicState: accountCryptographicState,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -253,6 +253,10 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
response.accountKeys.securityState.securityState,
|
||||
response.id,
|
||||
);
|
||||
await this.keyService.setSignedPublicKey(
|
||||
response.accountKeys.publicKeyEncryptionKeyPair.signedPublicKey,
|
||||
response.id,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.keyService.setPrivateKey(response.privateKey, response.id);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { HibpApiService } from "../dirt/services/hibp-api.service";
|
||||
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@@ -73,14 +72,16 @@ describe("AuditService", () => {
|
||||
expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("should return empty array for breachedAccounts on 404", async () => {
|
||||
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
|
||||
it("should return empty array for breachedAccounts when no breaches found", async () => {
|
||||
// Server returns 200 with empty array (correct REST semantics)
|
||||
mockHibpApi.getHibpBreach.mockResolvedValueOnce([]);
|
||||
const result = await auditService.breachedAccounts("user@example.com");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw error for breachedAccounts on non-404 error", async () => {
|
||||
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
|
||||
await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow();
|
||||
it("should propagate errors from breachedAccounts", async () => {
|
||||
const error = new Error("API error");
|
||||
mockHibpApi.getHibpBreach.mockRejectedValueOnce(error);
|
||||
await expect(auditService.breachedAccounts("user@example.com")).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.s
|
||||
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
|
||||
import { HibpApiService } from "../dirt/services/hibp-api.service";
|
||||
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ErrorResponse } from "../models/response/error.response";
|
||||
import { Utils } from "../platform/misc/utils";
|
||||
|
||||
const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/";
|
||||
@@ -70,14 +69,6 @@ export class AuditService implements AuditServiceAbstraction {
|
||||
}
|
||||
|
||||
async breachedAccounts(username: string): Promise<BreachAccountResponse[]> {
|
||||
try {
|
||||
return await this.hibpApiService.getHibpBreach(username);
|
||||
} catch (e) {
|
||||
const error = e as ErrorResponse;
|
||||
if (error.statusCode === 404) {
|
||||
return [];
|
||||
}
|
||||
throw new Error();
|
||||
}
|
||||
return this.hibpApiService.getHibpBreach(username);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockEnc } from "../../../../../spec";
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
|
||||
@@ -23,6 +23,8 @@ describe("SendAccess", () => {
|
||||
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creatorIdentifier: "creatorIdentifier",
|
||||
} as SendAccessResponse;
|
||||
|
||||
mockContainerService();
|
||||
});
|
||||
|
||||
it("Convert from empty", () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export class SendAccess extends Domain {
|
||||
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
|
||||
const model = new SendAccessView(this);
|
||||
|
||||
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
|
||||
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], key);
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.File:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockEnc } from "../../../../../spec";
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendFileData } from "../data/send-file.data";
|
||||
|
||||
import { SendFile } from "./send-file";
|
||||
@@ -39,6 +39,7 @@ describe("SendFile", () => {
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
mockContainerService();
|
||||
const sendFile = new SendFile();
|
||||
sendFile.id = "id";
|
||||
sendFile.size = "1100";
|
||||
|
||||
@@ -38,7 +38,6 @@ export class SendFile extends Domain {
|
||||
this,
|
||||
new SendFileView(this),
|
||||
["fileName"],
|
||||
null,
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockEnc } from "../../../../../spec";
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendTextData } from "../data/send-text.data";
|
||||
|
||||
import { SendText } from "./send-text";
|
||||
@@ -11,6 +11,8 @@ describe("SendText", () => {
|
||||
text: "encText",
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
mockContainerService();
|
||||
});
|
||||
|
||||
it("Convert from empty", () => {
|
||||
|
||||
@@ -30,13 +30,7 @@ export class SendText extends Domain {
|
||||
}
|
||||
|
||||
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
|
||||
return this.decryptObj<SendText, SendTextView>(
|
||||
this,
|
||||
new SendTextView(this),
|
||||
["text"],
|
||||
null,
|
||||
key,
|
||||
);
|
||||
return this.decryptObj<SendText, SendTextView>(this, new SendTextView(this), ["text"], key);
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SendText>) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { emptyGuid, UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { makeStaticByteArray, mockEnc } from "../../../../../spec";
|
||||
import { makeStaticByteArray, mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { EncryptService } from "../../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../../platform/services/container.service";
|
||||
@@ -43,6 +43,8 @@ describe("Send", () => {
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
};
|
||||
|
||||
mockContainerService();
|
||||
});
|
||||
|
||||
it("Convert from empty", () => {
|
||||
|
||||
@@ -89,7 +89,7 @@ export class Send extends Domain {
|
||||
model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey);
|
||||
model.cryptoKey = await keyService.makeSendKey(model.key);
|
||||
|
||||
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
|
||||
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], model.cryptoKey);
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.File:
|
||||
|
||||
@@ -4,10 +4,11 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$(): Observable<boolean>;
|
||||
abstract hasArchiveFlagEnabled$: Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract showArchiveVault$(userId: UserId): Observable<boolean>;
|
||||
abstract userHasPremium$(userId: UserId): Observable<boolean>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user