mirror of
https://github.com/bitwarden/browser
synced 2026-02-23 16:13:21 +00:00
Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-27632/sdk-cipher-ops
This commit is contained in:
@@ -2,33 +2,25 @@ import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
/**
|
||||
* Holds state that represents a user's account with Bitwarden.
|
||||
* Any additions here should be added to the equality check in the AccountService
|
||||
* to ensure that emissions are done on every change.
|
||||
*
|
||||
* @property email - User's email address.
|
||||
* @property emailVerified - Whether the email has been verified.
|
||||
* @property name - User's display name (optional).
|
||||
* @property creationDate - Date when the account was created.
|
||||
* Will be undefined immediately after login until the first sync completes.
|
||||
*/
|
||||
export type AccountInfo = {
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string | undefined;
|
||||
creationDate: string | undefined;
|
||||
creationDate: Date | undefined;
|
||||
};
|
||||
|
||||
export type Account = { id: UserId } & AccountInfo;
|
||||
|
||||
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
|
||||
for (const key of keys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export abstract class AccountService {
|
||||
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
|
||||
|
||||
@@ -77,7 +69,7 @@ export abstract class AccountService {
|
||||
* @param userId
|
||||
* @param creationDate
|
||||
*/
|
||||
abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise<void>;
|
||||
abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
|
||||
* @param userId
|
||||
|
||||
@@ -7,14 +7,6 @@ import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
|
||||
|
||||
export class AuthResult {
|
||||
userId: UserId;
|
||||
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
|
||||
/**
|
||||
* @deprecated
|
||||
* Replace with using UserDecryptionOptions to determine if the user does
|
||||
* not have a master password and is not using Key Connector.
|
||||
* */
|
||||
resetMasterPassword = false;
|
||||
|
||||
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
email: string;
|
||||
|
||||
@@ -116,4 +116,36 @@ describe("IdentityTokenResponse", () => {
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create response with accountKeys not present", () => {
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
AccountKeys: null as unknown,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accountKeysResponseModel).toBeNull();
|
||||
});
|
||||
|
||||
it("should create response with accountKeys present", () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
const response = {
|
||||
access_token: accessToken,
|
||||
token_type: tokenType,
|
||||
AccountKeys: accountKeysData,
|
||||
};
|
||||
|
||||
const identityTokenResponse = new IdentityTokenResponse(response);
|
||||
expect(identityTokenResponse.accountKeysResponseModel).toBeDefined();
|
||||
expect(
|
||||
identityTokenResponse.accountKeysResponseModel?.publicKeyEncryptionKeyPair,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { PrivateKeysResponseModel } from "../../../key-management/keys/response/private-keys.response";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
|
||||
@@ -18,9 +19,21 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
tokenType: string;
|
||||
|
||||
// Decryption Information
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
|
||||
/**
|
||||
* privateKey is actually userKeyEncryptedPrivateKey
|
||||
* @deprecated Use {@link accountKeysResponseModel} instead
|
||||
*/
|
||||
privateKey: string;
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys
|
||||
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
|
||||
|
||||
/**
|
||||
* key is actually masterKeyEncryptedUserKey
|
||||
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
|
||||
*/
|
||||
key?: EncString;
|
||||
twoFactorToken: string;
|
||||
kdfConfig: KdfConfig;
|
||||
forcePasswordReset: boolean;
|
||||
@@ -52,8 +65,12 @@ export class IdentityTokenResponse extends BaseResponse {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
if (this.getResponseProperty("AccountKeys") != null) {
|
||||
this.accountKeysResponseModel = new PrivateKeysResponseModel(
|
||||
this.getResponseProperty("AccountKeys"),
|
||||
);
|
||||
}
|
||||
const key = this.getResponseProperty("Key");
|
||||
if (key) {
|
||||
this.key = new EncString(key);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
|
||||
import { AccountInfo } from "../abstractions/account.service";
|
||||
|
||||
import {
|
||||
ACCOUNT_ACCOUNTS,
|
||||
@@ -27,63 +27,6 @@ import {
|
||||
AccountServiceImplementation,
|
||||
} from "./account.service";
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo = mockAccountInfoWith();
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect(accountInfoEqual(null, null)).toBe(true);
|
||||
expect(accountInfoEqual(null, accountInfo)).toBe(false);
|
||||
expect(accountInfoEqual(accountInfo, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares all keys, not just those defined in AccountInfo", () => {
|
||||
const different = { ...accountInfo, extra: "extra" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares name", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, name: "name2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares email", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, email: "email2" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares emailVerified", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, emailVerified: false };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares creationDate", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares undefined creationDate", () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
|
||||
const same = { ...accountWithoutCreationDate };
|
||||
const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" };
|
||||
|
||||
expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountService", () => {
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
@@ -121,6 +64,60 @@ describe("accountService", () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo = mockAccountInfoWith();
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect((sut as any).accountInfoEqual(null, null)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares name", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, name: "name2" };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares email", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, email: "email2" };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares emailVerified", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, emailVerified: false };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares creationDate", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") };
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares undefined creationDate", () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
|
||||
const same = { ...accountWithoutCreationDate };
|
||||
const different = {
|
||||
...accountWithoutCreationDate,
|
||||
creationDate: new Date("2024-01-01T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
|
||||
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeAccount$", () => {
|
||||
it("should emit null if no account is active", () => {
|
||||
const emissions = trackEmissions(sut.activeAccount$);
|
||||
@@ -281,7 +278,7 @@ describe("accountService", () => {
|
||||
});
|
||||
|
||||
it("should update the account with a new creation date", async () => {
|
||||
const newCreationDate = "2024-12-31T00:00:00.000Z";
|
||||
const newCreationDate = new Date("2024-12-31T00:00:00.000Z");
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
@@ -297,6 +294,24 @@ describe("accountService", () => {
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("should not update if the creation date has the same timestamp but different Date object", async () => {
|
||||
const sameTimestamp = new Date(userInfo.creationDate.getTime());
|
||||
await sut.setAccountCreationDate(userId, sameTimestamp);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("should update if the creation date has a different timestamp", async () => {
|
||||
const differentDate = new Date(userInfo.creationDate.getTime() + 1000);
|
||||
await sut.setAccountCreationDate(userId, differentDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: differentDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update from undefined to a defined creation date", async () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({
|
||||
...userInfo,
|
||||
@@ -304,7 +319,7 @@ describe("accountService", () => {
|
||||
});
|
||||
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
|
||||
|
||||
const newCreationDate = "2024-06-15T12:30:00.000Z";
|
||||
const newCreationDate = new Date("2024-06-15T12:30:00.000Z");
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
@@ -313,14 +328,19 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update to a different creation date string format", async () => {
|
||||
const newCreationDate = "2023-03-15T08:45:30.123Z";
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: newCreationDate },
|
||||
it("should not update when both creation dates are undefined", async () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({
|
||||
...userInfo,
|
||||
creationDate: undefined,
|
||||
});
|
||||
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
|
||||
|
||||
// Attempt to set to undefined (shouldn't trigger update)
|
||||
const currentStateBefore = await firstValueFrom(accountsState.state$);
|
||||
|
||||
// We can't directly call setAccountCreationDate with undefined, but we can verify
|
||||
// the behavior through setAccountInfo which accountInfoEqual uses internally
|
||||
expect(currentStateBefore[userId].creationDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Account,
|
||||
AccountInfo,
|
||||
InternalAccountService,
|
||||
accountInfoEqual,
|
||||
} from "../../auth/abstractions/account.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
@@ -37,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
|
||||
ACCOUNT_DISK,
|
||||
"accounts",
|
||||
{
|
||||
deserializer: (accountInfo) => accountInfo,
|
||||
deserializer: (accountInfo) => ({
|
||||
...accountInfo,
|
||||
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -111,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
|
||||
combineLatestWith(this.accounts$),
|
||||
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
this.accountActivity$ = this.globalStateProvider
|
||||
@@ -168,7 +170,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
await this.setAccountInfo(userId, { emailVerified });
|
||||
}
|
||||
|
||||
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
|
||||
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
|
||||
await this.setAccountInfo(userId, { creationDate });
|
||||
}
|
||||
|
||||
@@ -274,6 +276,23 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
this._showHeader$.next(visible);
|
||||
}
|
||||
|
||||
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
a.email === b.email &&
|
||||
a.emailVerified === b.emailVerified &&
|
||||
a.name === b.name &&
|
||||
a.creationDate?.getTime() === b.creationDate?.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
|
||||
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
|
||||
return { ...oldAccountInfo, ...update };
|
||||
@@ -291,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
throw new Error("Account does not exist");
|
||||
}
|
||||
|
||||
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
|
||||
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -19,6 +20,7 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
let mockBillingService: MockProxy<BillingAccountProfileStateService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationService: MockProxy<OrganizationService>;
|
||||
let mockPlatformService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
// RxJS Subjects we control in the tests
|
||||
let activeAccountSubject: BehaviorSubject<Account | null>;
|
||||
@@ -76,12 +78,15 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
mockOrganizationService = mock<OrganizationService>();
|
||||
mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
|
||||
|
||||
mockPlatformService = mock<PlatformUtilsService>();
|
||||
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
service = new PhishingDetectionSettingsService(
|
||||
mockAccountService,
|
||||
mockBillingService,
|
||||
mockConfigService,
|
||||
mockOrganizationService,
|
||||
mockPlatformService,
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
@@ -32,6 +33,7 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
private billingService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformService: PlatformUtilsService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.available$ = this.buildAvailablePipeline$().pipe(
|
||||
@@ -60,6 +62,11 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
* @returns An observable pipeline that determines if phishing detection is available
|
||||
*/
|
||||
private buildAvailablePipeline$(): Observable<boolean> {
|
||||
// Phishing detection is unavailable on Safari due to platform limitations.
|
||||
if (this.platformService.isSafari()) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.accountService.activeAccount$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
|
||||
|
||||
@@ -79,6 +79,8 @@ export enum EventType {
|
||||
Organization_CollectionManagement_LimitItemDeletionDisabled = 1615,
|
||||
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616,
|
||||
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
|
||||
Organization_ItemOrganization_Accepted = 1618,
|
||||
Organization_ItemOrganization_Declined = 1619,
|
||||
|
||||
Policy_Updated = 1700,
|
||||
|
||||
|
||||
@@ -22,14 +22,13 @@ export enum FeatureFlag {
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
WindowsDesktopAutotype = "windows-desktop-autotype",
|
||||
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
|
||||
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
@@ -45,11 +44,14 @@ export enum FeatureFlag {
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
|
||||
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
|
||||
SendUIRefresh = "pm-28175-send-ui-refresh",
|
||||
SendEmailOTP = "pm-19051-send-email-verification",
|
||||
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
@@ -61,7 +63,6 @@ export enum FeatureFlag {
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
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",
|
||||
@@ -70,8 +71,6 @@ export enum FeatureFlag {
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
|
||||
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
|
||||
|
||||
/* Innovation */
|
||||
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
||||
@@ -106,11 +105,14 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
|
||||
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
[FeatureFlag.SendUIRefresh]: FALSE,
|
||||
[FeatureFlag.SendEmailOTP]: FALSE,
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
@@ -122,7 +124,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
@@ -134,11 +135,9 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
@@ -154,11 +153,10 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
[FeatureFlag.InactiveUserServerNotification]: FALSE,
|
||||
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
|
||||
|
||||
/* Innovation */
|
||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AccountCryptographicStateService {
|
||||
/**
|
||||
* Emits the provided user's account cryptographic state or null if there is no account cryptographic state present for the user.
|
||||
*/
|
||||
abstract accountCryptographicState$(
|
||||
userId: UserId,
|
||||
): Observable<WrappedAccountCryptographicState | null>;
|
||||
|
||||
/**
|
||||
* Sets the account cryptographic state.
|
||||
* This is not yet validated, and is only validated upon SDK initialization.
|
||||
*/
|
||||
abstract setAccountCryptographicState(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { FakeStateProvider } from "@bitwarden/state-test-utils";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
||||
|
||||
import {
|
||||
ACCOUNT_CRYPTOGRAPHIC_STATE,
|
||||
DefaultAccountCryptographicStateService,
|
||||
} from "./default-account-cryptographic-state.service";
|
||||
|
||||
describe("DefaultAccountCryptographicStateService", () => {
|
||||
let service: DefaultAccountCryptographicStateService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
service = new DefaultAccountCryptographicStateService(stateProvider);
|
||||
});
|
||||
|
||||
describe("accountCryptographicState$", () => {
|
||||
it("returns null when no state is set", async () => {
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the account cryptographic state when set (V1)", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-wrapped-state" as any,
|
||||
},
|
||||
};
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("returns the account cryptographic state when set (V2)", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V2: {
|
||||
private_key: "test-wrapped-private-key" as any,
|
||||
signing_key: "test-wrapped-signing-key" as any,
|
||||
signed_public_key: "test-signed-public-key" as any,
|
||||
security_state: "test-security-state",
|
||||
},
|
||||
};
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("emits updated state when state changes", async () => {
|
||||
const mockState1: any = {
|
||||
V1: {
|
||||
private_key: "test-state-1" as any,
|
||||
},
|
||||
};
|
||||
const mockState2: any = {
|
||||
V1: {
|
||||
private_key: "test-state-2" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState1, mockUserId);
|
||||
|
||||
const observable = service.accountCryptographicState$(mockUserId);
|
||||
const results: (WrappedAccountCryptographicState | null)[] = [];
|
||||
const subscription = observable.subscribe((state) => results.push(state));
|
||||
|
||||
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState2, mockUserId);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual(mockState1);
|
||||
expect(results[1]).toEqual(mockState2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountCryptographicState", () => {
|
||||
it("sets the account cryptographic state", async () => {
|
||||
const mockState: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-wrapped-state" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await service.setAccountCryptographicState(mockState, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockState);
|
||||
});
|
||||
|
||||
it("overwrites existing state", async () => {
|
||||
const mockState1: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-state-1" as any,
|
||||
},
|
||||
};
|
||||
const mockState2: WrappedAccountCryptographicState = {
|
||||
V1: {
|
||||
private_key: "test-state-2" as any,
|
||||
},
|
||||
};
|
||||
|
||||
await service.setAccountCryptographicState(mockState1, mockUserId);
|
||||
await service.setAccountCryptographicState(mockState2, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockState2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACCOUNT_CRYPTOGRAPHIC_STATE key definition", () => {
|
||||
it("deserializer returns object as-is", () => {
|
||||
const mockState: any = {
|
||||
V1: {
|
||||
private_key: "test" as any,
|
||||
},
|
||||
};
|
||||
const result = ACCOUNT_CRYPTOGRAPHIC_STATE.deserializer(mockState);
|
||||
expect(result).toBe(mockState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
import { CRYPTO_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AccountCryptographicStateService } from "./account-cryptographic-state.service";
|
||||
|
||||
export const ACCOUNT_CRYPTOGRAPHIC_STATE = new UserKeyDefinition<WrappedAccountCryptographicState>(
|
||||
CRYPTO_DISK,
|
||||
"accountCryptographicState",
|
||||
{
|
||||
deserializer: (obj) => obj as WrappedAccountCryptographicState,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class DefaultAccountCryptographicStateService implements AccountCryptographicStateService {
|
||||
constructor(protected stateProvider: StateProvider) {}
|
||||
|
||||
accountCryptographicState$(userId: UserId): Observable<WrappedAccountCryptographicState | null> {
|
||||
return this.stateProvider.getUserState$(ACCOUNT_CRYPTOGRAPHIC_STATE, userId);
|
||||
}
|
||||
|
||||
async setAccountCryptographicState(
|
||||
accountCryptographicState: WrappedAccountCryptographicState,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
await this.stateProvider.setUserState(
|
||||
ACCOUNT_CRYPTOGRAPHIC_STATE,
|
||||
accountCryptographicState,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export abstract class DeviceTrustServiceAbstraction {
|
||||
|
||||
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
|
||||
abstract getDeviceKey(userId: UserId): Promise<DeviceKey | null>;
|
||||
abstract setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void>;
|
||||
abstract decryptUserKeyWithDeviceKey(
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
|
||||
@@ -356,7 +356,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set device key.");
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { makeEncString } from "../../../spec";
|
||||
import { KdfRequest } from "../../models/request/kdf.request";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncString } from "../crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationHash,
|
||||
@@ -22,6 +23,8 @@ import { DefaultChangeKdfService } from "./change-kdf.service";
|
||||
describe("ChangeKdfService", () => {
|
||||
const changeKdfApiService = mock<ChangeKdfApiService>();
|
||||
const sdkService = mock<SdkService>();
|
||||
const keyService = mock<KeyService>();
|
||||
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
|
||||
let sut: DefaultChangeKdfService;
|
||||
|
||||
@@ -48,7 +51,12 @@ describe("ChangeKdfService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
|
||||
sut = new DefaultChangeKdfService(changeKdfApiService, sdkService);
|
||||
sut = new DefaultChangeKdfService(
|
||||
changeKdfApiService,
|
||||
sdkService,
|
||||
keyService,
|
||||
masterPasswordService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,6 +171,20 @@ describe("ChangeKdfService", () => {
|
||||
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expectedRequest);
|
||||
});
|
||||
|
||||
it("should set master key and hash after KDF update", async () => {
|
||||
const masterPassword = "masterPassword";
|
||||
const mockMasterKey = {} as any;
|
||||
const mockHash = "localHash";
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue(mockHash);
|
||||
|
||||
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
|
||||
|
||||
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
|
||||
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockHash, mockUserId);
|
||||
});
|
||||
|
||||
it("should properly dispose of SDK resources", async () => {
|
||||
const masterPassword = "masterPassword";
|
||||
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
import { KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { KdfRequest } from "../../models/request/kdf.request";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
fromSdkAuthenticationData,
|
||||
MasterPasswordAuthenticationData,
|
||||
@@ -20,6 +22,8 @@ export class DefaultChangeKdfService implements ChangeKdfService {
|
||||
constructor(
|
||||
private changeKdfApiService: ChangeKdfApiService,
|
||||
private sdkService: SdkService,
|
||||
private keyService: KeyService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
|
||||
@@ -56,5 +60,19 @@ export class DefaultChangeKdfService implements ChangeKdfService {
|
||||
const request = new KdfRequest(authenticationData, unlockData);
|
||||
request.authenticateWith(oldAuthenticationData);
|
||||
await this.changeKdfApiService.updateUserKdfParams(request);
|
||||
|
||||
// Update the locally stored master key and hash, so that UV, etc. still works
|
||||
const masterKey = await this.keyService.makeMasterKey(
|
||||
masterPassword,
|
||||
unlockData.salt,
|
||||
unlockData.kdf,
|
||||
);
|
||||
const localMasterKeyHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SignedPublicKey, WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SecurityStateResponse } from "../../security-state/response/security-state.response";
|
||||
|
||||
import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
|
||||
@@ -52,4 +54,31 @@ export class PrivateKeysResponseModel {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toWrappedAccountCryptographicState(): WrappedAccountCryptographicState {
|
||||
if (this.signatureKeyPair === null && this.securityState === null) {
|
||||
// V1 user
|
||||
return {
|
||||
V1: {
|
||||
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
},
|
||||
};
|
||||
} else if (this.signatureKeyPair !== null && this.securityState !== null) {
|
||||
// V2 user
|
||||
return {
|
||||
V2: {
|
||||
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signing_key: this.signatureKeyPair.wrappedSigningKey,
|
||||
signed_public_key: this.publicKeyEncryptionKeyPair.signedPublicKey as SignedPublicKey,
|
||||
security_state: this.securityState.securityState as string,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error("Both signatureKeyPair and securityState must be present or absent together");
|
||||
}
|
||||
}
|
||||
|
||||
isV2Encryption(): boolean {
|
||||
return this.signatureKeyPair !== null && this.securityState !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../../spec";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
@@ -130,15 +129,6 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
|
||||
|
||||
configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
|
||||
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
|
||||
[FeatureFlag.InactiveUserServerNotification]: true,
|
||||
[FeatureFlag.PushNotificationsWhenLocked]: true,
|
||||
};
|
||||
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
|
||||
});
|
||||
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
defaultServerNotificationsService = new DefaultServerNotificationsService(
|
||||
|
||||
@@ -71,48 +71,20 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
private readonly configService: ConfigService,
|
||||
private readonly policyService: InternalPolicyService,
|
||||
) {
|
||||
this.notifications$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification)
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((inactiveUserServerNotificationEnabled) => {
|
||||
if (inactiveUserServerNotificationEnabled) {
|
||||
return this.accountService.accounts$.pipe(
|
||||
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
|
||||
const validUserIds = Object.entries(accounts)
|
||||
.filter(
|
||||
([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified,
|
||||
)
|
||||
.map(([userId, _]) => userId as UserId);
|
||||
return new Set(validUserIds);
|
||||
}),
|
||||
trackedMerge((id: UserId) => {
|
||||
return this.userNotifications$(id as UserId).pipe(
|
||||
map(
|
||||
(notification: NotificationResponse) => [notification, id as UserId] as const,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
distinctUntilChanged(),
|
||||
switchMap((activeAccountId) => {
|
||||
if (activeAccountId == null) {
|
||||
// We don't emit server-notifications for inactive accounts currently
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.userNotifications$(activeAccountId).pipe(
|
||||
map((notification) => [notification, activeAccountId] as const),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
share(), // Multiple subscribers should only create a single connection to the server
|
||||
);
|
||||
this.notifications$ = this.accountService.accounts$.pipe(
|
||||
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
|
||||
const validUserIds = Object.entries(accounts)
|
||||
.filter(([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified)
|
||||
.map(([userId, _]) => userId as UserId);
|
||||
return new Set(validUserIds);
|
||||
}),
|
||||
trackedMerge((id: UserId) => {
|
||||
return this.userNotifications$(id as UserId).pipe(
|
||||
map((notification: NotificationResponse) => [notification, id as UserId] as const),
|
||||
);
|
||||
}),
|
||||
share(), // Multiple subscribers should only create a single connection to the server
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,25 +147,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
}
|
||||
|
||||
private hasAccessToken$(userId: UserId) {
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PushNotificationsWhenLocked).pipe(
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map(
|
||||
(authStatus) =>
|
||||
authStatus === AuthenticationStatus.Locked ||
|
||||
authStatus === AuthenticationStatus.Unlocked,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
switchMap((featureFlagEnabled) => {
|
||||
if (featureFlagEnabled) {
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map(
|
||||
(authStatus) =>
|
||||
authStatus === AuthenticationStatus.Locked ||
|
||||
authStatus === AuthenticationStatus.Unlocked,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
} else {
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,19 +168,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification),
|
||||
)
|
||||
) {
|
||||
const activeAccountId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const activeAccountId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
|
||||
const isActiveUser = activeAccountId === userId;
|
||||
if (!isActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) {
|
||||
return;
|
||||
}
|
||||
const notificationIsForActiveUser = activeAccountId === userId;
|
||||
if (!notificationIsForActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (notification.type) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
@@ -27,7 +27,9 @@ describe("Encrypted private key", () => {
|
||||
it("should deserialize encrypted private key", () => {
|
||||
const encryptedPrivateKey = makeEncString().encryptedString;
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedPrivateKey)));
|
||||
const result = sut.deserializer(
|
||||
JSON.parse(JSON.stringify(encryptedPrivateKey as unknown)) as unknown as EncryptedString,
|
||||
);
|
||||
|
||||
expect(result).toEqual(encryptedPrivateKey);
|
||||
});
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -29,6 +27,8 @@ import { TokenService } from "../../auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||
import { BillingAccountProfileStateService } from "../../billing/abstractions";
|
||||
import { AccountCryptographicStateService } from "../../key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncString } from "../../key-management/crypto/models/enc-string";
|
||||
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "../../key-management/master-password/types/master-password.types";
|
||||
import { SecurityStateService } from "../../key-management/security-state/abstractions/security-state.service";
|
||||
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
|
||||
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "../../types/guid";
|
||||
@@ -76,6 +77,7 @@ describe("DefaultSyncService", () => {
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let securityStateService: MockProxy<SecurityStateService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let sut: DefaultSyncService;
|
||||
|
||||
@@ -107,6 +109,7 @@ describe("DefaultSyncService", () => {
|
||||
stateProvider = mock();
|
||||
securityStateService = mock();
|
||||
kdfConfigService = mock();
|
||||
accountCryptographicStateService = mock();
|
||||
|
||||
sut = new DefaultSyncService(
|
||||
masterPasswordAbstraction,
|
||||
@@ -135,6 +138,7 @@ describe("DefaultSyncService", () => {
|
||||
stateProvider,
|
||||
securityStateService,
|
||||
kdfConfigService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -101,6 +102,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
stateProvider: StateProvider,
|
||||
private securityStateService: SecurityStateService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private accountCryptographicStateService: AccountCryptographicStateService,
|
||||
) {
|
||||
super(
|
||||
tokenService,
|
||||
@@ -239,12 +241,18 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
|
||||
// Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768
|
||||
if (response.accountKeys != null) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
response.accountKeys.toWrappedAccountCryptographicState(),
|
||||
response.id,
|
||||
);
|
||||
|
||||
// V1 and V2 users
|
||||
await this.keyService.setPrivateKey(
|
||||
response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
response.id,
|
||||
);
|
||||
if (response.accountKeys.signatureKeyPair !== null) {
|
||||
// User is V2 user
|
||||
// V2 users only
|
||||
if (response.accountKeys.isV2Encryption()) {
|
||||
await this.keyService.setUserSigningKey(
|
||||
response.accountKeys.signatureKeyPair.wrappedSigningKey,
|
||||
response.id,
|
||||
@@ -271,8 +279,8 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
|
||||
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
|
||||
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
|
||||
await this.accountService.setAccountCreationDate(response.id, new Date(response.creationDate));
|
||||
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
|
||||
await this.accountService.setAccountCreationDate(response.id, response.creationDate);
|
||||
|
||||
await this.billingAccountProfileStateService.setHasPremium(
|
||||
response.premiumPersonally,
|
||||
|
||||
@@ -209,9 +209,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* Update the local store of CipherData with the provided data. Values are upserted into the existing store.
|
||||
*
|
||||
* @param cipher The cipher data to upsert. Can be a single CipherData object or an array of CipherData objects.
|
||||
* @param userId Optional user ID for whom the cipher data is being upserted.
|
||||
* @returns A promise that resolves to a record of updated cipher store, keyed by their cipher ID. Returns all ciphers, not just those updated
|
||||
*/
|
||||
abstract upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>>;
|
||||
abstract upsert(
|
||||
cipher: CipherData | CipherData[],
|
||||
userId?: UserId,
|
||||
): Promise<Record<CipherId, CipherData>>;
|
||||
abstract replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any>;
|
||||
abstract clear(userId?: string): Promise<void>;
|
||||
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
|
||||
|
||||
@@ -1296,12 +1296,15 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptedCiphersState(userId).update(() => ciphers);
|
||||
}
|
||||
|
||||
async upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>> {
|
||||
async upsert(
|
||||
cipher: CipherData | CipherData[],
|
||||
userId?: UserId,
|
||||
): Promise<Record<CipherId, CipherData>> {
|
||||
const ciphers = cipher instanceof CipherData ? [cipher] : cipher;
|
||||
const res = await this.updateEncryptedCipherState((current) => {
|
||||
ciphers.forEach((c) => (current[c.id as CipherId] = c));
|
||||
return current;
|
||||
});
|
||||
}, userId);
|
||||
// Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick
|
||||
// Otherwise, subscribers to cipherViews$ can get stale data
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -219,7 +219,7 @@ describe("DefaultCipherArchiveService", () => {
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
mockCipherService.replace.mockResolvedValue(undefined);
|
||||
mockCipherService.upsert.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should archive single cipher", async () => {
|
||||
@@ -233,13 +233,13 @@ describe("DefaultCipherArchiveService", () => {
|
||||
true,
|
||||
);
|
||||
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
|
||||
expect(mockCipherService.replace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
[cipherId]: expect.objectContaining({
|
||||
expect(mockCipherService.upsert).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
archivedDate: "2024-01-15T10:30:00.000Z",
|
||||
revisionDate: "2024-01-15T10:31:00.000Z",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
@@ -282,7 +282,7 @@ describe("DefaultCipherArchiveService", () => {
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
mockCipherService.replace.mockResolvedValue(undefined);
|
||||
mockCipherService.upsert.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should unarchive single cipher", async () => {
|
||||
@@ -296,12 +296,12 @@ describe("DefaultCipherArchiveService", () => {
|
||||
true,
|
||||
);
|
||||
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
|
||||
expect(mockCipherService.replace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
[cipherId]: expect.objectContaining({
|
||||
expect(mockCipherService.upsert).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
revisionDate: "2024-01-15T10:31:00.000Z",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.replace(currentCiphers, userId);
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
}
|
||||
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
@@ -116,6 +116,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.replace(currentCiphers, userId);
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user