1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-07 12:13:45 +00:00

Merge branch 'main' into desktop/pm-18769/migrate-vault-filters

This commit is contained in:
Leslie Xiong
2026-01-02 12:40:17 -05:00
181 changed files with 6039 additions and 1846 deletions

View File

@@ -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

View File

@@ -19,9 +19,21 @@ export class IdentityTokenResponse extends BaseResponse {
tokenType: string;
// Decryption Information
privateKey: string; // userKeyEncryptedPrivateKey
/**
* 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?: EncString; // masterKeyEncryptedUserKey
/**
* key is actually masterKeyEncryptedUserKey
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
*/
key?: EncString;
twoFactorToken: string;
kdfConfig: KdfConfig;
forcePasswordReset: boolean;

View File

@@ -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();
});
});

View File

@@ -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]));
},
},
);

View File

@@ -26,7 +26,6 @@ export enum FeatureFlag {
/* 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",
@@ -45,6 +44,8 @@ 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",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
@@ -63,7 +64,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",
@@ -71,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",
@@ -126,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,
@@ -137,7 +134,6 @@ export const DefaultFeatureFlagValue = {
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
@@ -156,11 +152,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,

View File

@@ -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,

View File

@@ -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.");
}

View File

@@ -5,5 +5,6 @@ import { KdfConfig } from "@bitwarden/key-management";
export interface NewSsoUserKeyConnectorConversion {
kdfConfig: KdfConfig;
keyConnectorUrl: string;
// SSO organization identifier, not UUID
organizationId: string;
}

View File

@@ -7,7 +7,8 @@ import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
// 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 { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
@@ -16,21 +17,26 @@ import { Organization } from "../../../admin-console/models/domain/organization"
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
import { TokenService } from "../../../auth/services/token.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { Rc } from "../../../platform/misc/reference-counting/rc";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion";
import {
USES_KEY_CONNECTOR,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
KeyConnectorService,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
USES_KEY_CONNECTOR,
} from "./key-connector.service";
describe("KeyConnectorService", () => {
@@ -43,6 +49,10 @@ describe("KeyConnectorService", () => {
const organizationService = mock<OrganizationService>();
const keyGenerationService = mock<KeyGenerationService>();
const logoutCallback = jest.fn();
const configService = mock<ConfigService>();
const registerSdkService = mock<RegisterSdkService>();
const securityStateService = mock<SecurityStateService>();
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
let stateProvider: FakeStateProvider;
@@ -50,6 +60,7 @@ describe("KeyConnectorService", () => {
let masterPasswordService: FakeMasterPasswordService;
const mockUserId = Utils.newGuid() as UserId;
const mockSsoOrgIdentifier = "test-sso-org-id";
const mockOrgId = Utils.newGuid() as OrganizationId;
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
@@ -61,7 +72,7 @@ describe("KeyConnectorService", () => {
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: new PBKDF2KdfConfig(600_000),
keyConnectorUrl,
organizationId: mockOrgId,
organizationId: mockSsoOrgIdentifier,
};
beforeEach(() => {
@@ -82,6 +93,10 @@ describe("KeyConnectorService", () => {
keyGenerationService,
logoutCallback,
stateProvider,
configService,
registerSdkService,
securityStateService,
accountCryptographicStateService,
);
});
@@ -419,44 +434,52 @@ describe("KeyConnectorService", () => {
});
describe("convertNewSsoUserToKeyConnector", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
describe("V2", () => {
const mockKeyConnectorKey = Utils.fromBufferToB64(new Uint8Array(64));
const mockUserKeyString = Utils.fromBufferToB64(new Uint8Array(64));
const mockPrivateKey = "mockPrivateKey789";
const mockKeyConnectorKeyWrappedUserKey = "2.mockWrappedUserKey";
const mockSigningKey = "mockSigningKey";
const mockSignedPublicKey = "mockSignedPublicKey";
const mockSecurityState = "mockSecurityState";
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
let mockSdkRef: any;
let mockSdk: any;
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
});
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockOrgId,
mockSdkRef = {
value: {
auth: jest.fn().mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_key_connector_registration: jest.fn().mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
}),
}),
}),
},
[Symbol.dispose]: jest.fn(),
};
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
});
it("should set up a new SSO user with key connector using V2", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
@@ -465,11 +488,253 @@ describe("KeyConnectorService", () => {
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
expect(mockSdk.take).toHaveBeenCalled();
expect(mockSdkRef.value.auth).toHaveBeenCalled();
const mockRegistration = mockSdkRef.value
.auth()
.registration().post_keys_for_key_connector_registration;
expect(mockRegistration).toHaveBeenCalledWith(
keyConnectorUrl,
mockSsoOrgIdentifier,
mockUserId,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect.any(EncString),
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
mockUserId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(await firstValueFrom(conversionState.state$)).toBeNull();
});
it("should throw error when SDK is not available", async () => {
registerSdkService.registerClient$.mockReturnValue(
of(null as unknown as Rc<BitwardenClient>),
);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("SDK not available");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when account cryptographic state is not V2", async () => {
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V1: {
private_key: mockPrivateKey,
},
},
});
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Unexpected account cryptographic state version");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when post_keys_for_key_connector_registration fails", async () => {
const sdkError = new Error("Key Connector registration failed");
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockRejectedValue(sdkError);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Key Connector registration failed");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
});
describe("V1", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockSsoOrgIdentifier,
};
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockSsoOrgIdentifier,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector error"));
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
@@ -488,76 +753,29 @@ describe("KeyConnectorService", () => {
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockOrgId,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector conversion not found"));
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector error"),
);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)),
);
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector conversion not found"),
);
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,22 +9,36 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
// 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 { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
Argon2KdfConfig,
KdfConfig,
KdfType,
KeyService,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../../admin-console/enums";
import { Organization } from "../../../admin-console/models/domain/organization";
import { TokenService } from "../../../auth/abstractions/token.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeysRequest } from "../../../models/request/keys.request";
import { LogService } from "../../../platform/abstractions/log.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "../../../platform/abstractions/sdk/sdk.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { SignedPublicKey, SignedSecurityState, WrappedSigningKey } from "../../types";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
@@ -75,6 +89,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
private stateProvider: StateProvider,
private configService: ConfigService,
private registerSdkService: RegisterSdkService,
private securityStateService: SecurityStateService,
private accountCryptographicStateService: AccountCryptographicStateService,
) {
this.convertAccountRequired$ = accountService.activeAccount$.pipe(
filter((account) => account != null),
@@ -152,8 +170,106 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
throw new Error("Key Connector conversion not found");
}
const { kdfConfig, keyConnectorUrl, organizationId } = conversion;
const { kdfConfig, keyConnectorUrl, organizationId: ssoOrganizationIdentifier } = conversion;
if (
await firstValueFrom(
this.configService.getFeatureFlag$(
FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration,
),
)
) {
await this.convertNewSsoUserToKeyConnectorV2(
userId,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
} else {
await this.convertNewSsoUserToKeyConnectorV1(
userId,
kdfConfig,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
}
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async convertNewSsoUserToKeyConnectorV2(
userId: UserId,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const result = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return ref.value
.auth()
.registration()
.post_keys_for_key_connector_registration(
keyConnectorUrl,
ssoOrganizationIdentifier,
asUuid(userId),
);
}),
),
);
if (!("V2" in result.account_cryptographic_state)) {
const version = Object.keys(result.account_cryptographic_state);
throw new Error(`Unexpected account cryptographic state version ${version}`);
}
await this.masterPasswordService.setMasterKey(
SymmetricCryptoKey.fromString(result.key_connector_key) as MasterKey,
userId,
);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(result.user_key) as UserKey,
userId,
);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
new EncString(result.key_connector_key_wrapped_user_key),
userId,
);
await this.accountCryptographicStateService.setAccountCryptographicState(
result.account_cryptographic_state,
userId,
);
// Legacy states
await this.keyService.setPrivateKey(result.account_cryptographic_state.V2.private_key, userId);
await this.keyService.setUserSigningKey(
result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
if (result.account_cryptographic_state.V2.signed_public_key != null) {
await this.keyService.setSignedPublicKey(
result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
}
}
async convertNewSsoUserToKeyConnectorV1(
userId: UserId,
kdfConfig: KdfConfig,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const password = await this.keyGenerationService.createKey(512);
const masterKey = await this.keyService.makeMasterKey(
@@ -182,14 +298,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
const setPasswordRequest = new SetKeyConnectorKeyRequest(
userKey[1].encryptedString,
kdfConfig,
organizationId,
ssoOrganizationIdentifier,
keys,
);
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async setNewSsoUserKeyConnectorConversionData(

View File

@@ -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(

View File

@@ -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) {

View File

@@ -279,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,

View File

@@ -207,9 +207,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>;

View File

@@ -1196,12 +1196,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));

View File

@@ -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,
);
});

View File

@@ -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);
}
}