diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index 5fd1866c830..4a0dd07b322 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -27,9 +27,9 @@ import { LogServiceInitOptions, } from "../../../platform/background/service-factories/log-service.factory"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; @@ -40,13 +40,13 @@ type KeyConnectorServiceFactoryOptions = FactoryOptions & { }; export type KeyConnectorServiceInitOptions = KeyConnectorServiceFactoryOptions & - StateServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & LogServiceInitOptions & OrganizationServiceInitOptions & - KeyGenerationServiceInitOptions; + KeyGenerationServiceInitOptions & + StateProviderInitOptions; export function keyConnectorServiceFactory( cache: { keyConnectorService?: AbstractKeyConnectorService } & CachedServices, @@ -58,7 +58,6 @@ export function keyConnectorServiceFactory( opts, async () => new KeyConnectorService( - await stateServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), @@ -66,6 +65,7 @@ export function keyConnectorServiceFactory( await organizationServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), opts.keyConnectorServiceOptions.logoutCallback, + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 760b833044e..596d6b7235e 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -30,6 +30,7 @@ import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; +import { KeyConnectorServiceInitOptions } from "../../auth/background/service-factories/key-connector-service.factory"; import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory"; import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { autofillSettingsServiceFactory } from "../../autofill/background/service_factories/autofill-settings-service.factory"; @@ -78,7 +79,9 @@ export class ContextMenuClickedHandler { static async mv3Create(cachedServices: CachedServices) { const stateFactory = new StateFactory(GlobalState, Account); - const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = { + const serviceOptions: AuthServiceInitOptions & + CipherServiceInitOptions & + KeyConnectorServiceInitOptions = { apiServiceOptions: { logoutCallback: NOT_IMPLEMENTED, }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index c2c8c5be724..5bb47ab68a1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -514,7 +514,6 @@ export default class MainBackground { this.badgeSettingsService = new BadgeSettingsService(this.stateProvider); this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( - this.stateService, this.cryptoService, this.apiService, this.tokenService, @@ -522,6 +521,7 @@ export default class MainBackground { this.organizationService, this.keyGenerationService, logoutCallback, + this.stateProvider, ); this.passwordStrengthService = new PasswordStrengthService(); @@ -1125,7 +1125,6 @@ export default class MainBackground { this.policyService.clear(userId), this.passwordGenerationService.clear(userId), this.vaultTimeoutSettingsService.clear(userId), - this.keyConnectorService.clear(), this.vaultFilterService.clear(), this.biometricStateService.logout(userId), this.providerService.save(null, userId), diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index ce2152ffbf3..7f23e6f2d0f 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -427,7 +427,6 @@ export class Main { this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( - this.stateService, this.cryptoService, this.apiService, this.tokenService, @@ -435,6 +434,7 @@ export class Main { this.organizationService, this.keyGenerationService, async (expired: boolean) => await this.logout(), + this.stateProvider, ); this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 196bebfcf74..4e74135c498 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -584,7 +584,6 @@ export class AppComponent implements OnInit, OnDestroy { await this.passwordGenerationService.clear(userBeingLoggedOut); await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.policyService.clear(userBeingLoggedOut); - await this.keyConnectorService.clear(); await this.biometricStateService.logout(userBeingLoggedOut as UserId); await this.providerService.save(null, userBeingLoggedOut as UserId); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 23b45618c68..32f4ee67e20 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -276,7 +276,6 @@ export class AppComponent implements OnDestroy, OnInit { this.collectionService.clear(userId), this.policyService.clear(userId), this.passwordGenerationService.clear(), - this.keyConnectorService.clear(), this.biometricStateService.logout(userId as UserId), this.paymentMethodWarningService.clear(), ]); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b2f5ee1f898..841edb4289a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -765,7 +765,6 @@ const safeProviders: SafeProvider[] = [ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, deps: [ - StateServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -773,6 +772,7 @@ const safeProviders: SafeProvider[] = [ OrganizationServiceAbstraction, KeyGenerationServiceAbstraction, LOGOUT_CALLBACK, + StateProvider, ], }), safeProvider({ diff --git a/libs/common/src/auth/abstractions/key-connector.service.ts b/libs/common/src/auth/abstractions/key-connector.service.ts index b7c8d5d0d0b..36f413d70c7 100644 --- a/libs/common/src/auth/abstractions/key-connector.service.ts +++ b/libs/common/src/auth/abstractions/key-connector.service.ts @@ -15,5 +15,4 @@ export abstract class KeyConnectorService { setConvertAccountRequired: (status: boolean) => Promise; getConvertAccountRequired: () => Promise; removeConvertAccountRequired: () => Promise; - clear: () => Promise; } diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts new file mode 100644 index 00000000000..50fed856f97 --- /dev/null +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -0,0 +1,376 @@ +import { mock } from "jest-mock-extended"; + +import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; +import { ApiService } from "../../abstractions/api.service"; +import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationData } from "../../admin-console/models/data/organization.data"; +import { Organization } from "../../admin-console/models/domain/organization"; +import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { Utils } from "../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { KeyGenerationService } from "../../platform/services/key-generation.service"; +import { OrganizationId, UserId } from "../../types/guid"; +import { MasterKey } from "../../types/key"; +import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; +import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response"; + +import { + USES_KEY_CONNECTOR, + CONVERT_ACCOUNT_TO_KEY_CONNECTOR, + KeyConnectorService, +} from "./key-connector.service"; +import { TokenService } from "./token.service"; + +describe("KeyConnectorService", () => { + let keyConnectorService: KeyConnectorService; + + const cryptoService = mock(); + const apiService = mock(); + const tokenService = mock(); + const logService = mock(); + const organizationService = mock(); + const keyGenerationService = mock(); + + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + + const mockUserId = Utils.newGuid() as UserId; + const mockOrgId = Utils.newGuid() as OrganizationId; + + const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({ + key: "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==", + }); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + keyConnectorService = new KeyConnectorService( + cryptoService, + apiService, + tokenService, + logService, + organizationService, + keyGenerationService, + async () => {}, + stateProvider, + ); + }); + + it("instantiates", () => { + expect(keyConnectorService).not.toBeFalsy(); + }); + + describe("setUsesKeyConnector()", () => { + it("should update the usesKeyConnectorState with the provided value", async () => { + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(false); + + const newValue = true; + + await keyConnectorService.setUsesKeyConnector(newValue); + + expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue); + }); + }); + + describe("getManagingOrganization()", () => { + it("should return the managing organization with key connector enabled", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 2, false), + organizationData(false, true, "https://key-connector-url.com", 2, false), + organizationData(true, false, "https://key-connector-url.com", 2, false), + organizationData(true, true, "https://other-url.com", 2, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toEqual(orgs[0]); + }); + + it("should return undefined if no managing organization with key connector enabled is found", async () => { + // Arrange + const orgs = [ + organizationData(true, false, "https://key-connector-url.com", 2, false), + organizationData(false, false, "https://key-connector-url.com", 2, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should return undefined if user is Owner or Admin", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 0, false), + organizationData(true, true, "https://key-connector-url.com", 1, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should return undefined if user is a Provider", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 2, true), + organizationData(false, true, "https://key-connector-url.com", 2, true), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe("setConvertAccountRequired()", () => { + it("should update the convertAccountToKeyConnectorState with the provided value", async () => { + const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR); + state.nextState(false); + + const newValue = true; + + await keyConnectorService.setConvertAccountRequired(newValue); + + expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue); + }); + + it("should remove the convertAccountToKeyConnectorState", async () => { + const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR); + state.nextState(false); + + const newValue: boolean = null; + + await keyConnectorService.setConvertAccountRequired(newValue); + + expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue); + }); + }); + + describe("userNeedsMigration()", () => { + it("should return true if the user needs migration", async () => { + // token + tokenService.getIsExternal.mockResolvedValue(true); + + // create organization object + const data = organizationData(true, true, "https://key-connector-url.com", 2, false); + organizationService.getAll.mockResolvedValue([data]); + + // uses KeyConnector + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(false); + + const result = await keyConnectorService.userNeedsMigration(); + + expect(result).toBe(true); + }); + + it("should return false if the user does not need migration", async () => { + tokenService.getIsExternal.mockResolvedValue(false); + const data = organizationData(false, false, "https://key-connector-url.com", 2, false); + organizationService.getAll.mockResolvedValue([data]); + + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(true); + const result = await keyConnectorService.userNeedsMigration(); + + expect(result).toBe(false); + }); + }); + + describe("setMasterKeyFromUrl", () => { + it("should set the master key from the provided URL", async () => { + // Arrange + const url = "https://key-connector-url.com"; + + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + + // Hard to mock these, but we can generate the same keys + const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); + const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + + // Act + await keyConnectorService.setMasterKeyFromUrl(url); + + // Assert + expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); + expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + }); + + it("should handle errors thrown during the process", async () => { + // Arrange + const url = "https://key-connector-url.com"; + + const error = new Error("Failed to get master key"); + apiService.getMasterKeyFromKeyConnector.mockRejectedValue(error); + jest.spyOn(logService, "error"); + + try { + // Act + await keyConnectorService.setMasterKeyFromUrl(url); + } catch { + // Assert + expect(logService.error).toHaveBeenCalledWith(error); + expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); + } + }); + }); + + describe("migrateUser()", () => { + it("should migrate the user to the key connector", async () => { + // Arrange + const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); + const masterKey = getMockMasterKey(); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + + jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); + jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); + jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); + + // Act + await keyConnectorService.migrateUser(); + + // Assert + expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); + expect(cryptoService.getMasterKey).toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + expect(apiService.postConvertToKeyConnector).toHaveBeenCalled(); + }); + + it("should handle errors thrown during migration", async () => { + // Arrange + const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); + const masterKey = getMockMasterKey(); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const error = new Error("Failed to post user key to key connector"); + organizationService.getAll.mockResolvedValue([organization]); + + jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); + jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); + jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error); + jest.spyOn(logService, "error"); + + try { + // Act + await keyConnectorService.migrateUser(); + } catch { + // Assert + expect(logService.error).toHaveBeenCalledWith(error); + expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); + expect(cryptoService.getMasterKey).toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + } + }); + }); + + function organizationData( + usesKeyConnector: boolean, + keyConnectorEnabled: boolean, + keyConnectorUrl: string, + userType: number, + isProviderUser: boolean, + ): Organization { + return new Organization( + new OrganizationData( + new ProfileOrganizationResponse({ + id: mockOrgId, + name: "TEST_KEY_CONNECTOR_ORG", + usePolicies: true, + useSso: true, + useKeyConnector: usesKeyConnector, + useScim: true, + useGroups: true, + useDirectory: true, + useEvents: true, + useTotp: true, + use2fa: true, + useApi: true, + useResetPassword: true, + useSecretsManager: true, + usePasswordManager: true, + usersGetPremium: true, + useCustomPermissions: true, + useActivateAutofillPolicy: true, + selfHost: true, + seats: 5, + maxCollections: null, + maxStorageGb: 1, + key: "super-secret-key", + status: 2, + type: userType, + enabled: true, + ssoBound: true, + identifier: "TEST_KEY_CONNECTOR_ORG", + permissions: { + accessEventLogs: false, + accessImportExport: false, + accessReports: false, + createNewCollections: false, + editAnyCollection: false, + deleteAnyCollection: false, + editAssignedCollections: false, + deleteAssignedCollections: false, + manageGroups: false, + managePolicies: false, + manageSso: false, + manageUsers: false, + manageResetPassword: false, + manageScim: false, + }, + resetPasswordEnrolled: true, + userId: mockUserId, + hasPublicAndPrivateKeys: true, + providerId: null, + providerName: null, + providerType: null, + familySponsorshipFriendlyName: null, + familySponsorshipAvailable: true, + planProductType: 3, + KeyConnectorEnabled: keyConnectorEnabled, + KeyConnectorUrl: keyConnectorUrl, + familySponsorshipLastSyncDate: null, + familySponsorshipValidUntil: null, + familySponsorshipToDelete: null, + accessSecretsManager: false, + limitCollectionCreationDeletion: true, + allowAdminAccessToAllCollectionItems: true, + flexibleCollections: false, + object: "profileOrganization", + }), + { isMember: true, isProviderUser: isProviderUser }, + ), + ); + } + + function getMockMasterKey(): MasterKey { + const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); + const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + return masterKey; + } +}); diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index cded13a74bf..d1502ce06c3 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../admin-console/enums"; @@ -5,9 +7,14 @@ import { KeysRequest } from "../../models/request/keys.request"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { LogService } from "../../platform/abstractions/log.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { + ActiveUserState, + KEY_CONNECTOR_DISK, + StateProvider, + UserKeyDefinition, +} from "../../platform/state"; import { MasterKey } from "../../types/key"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; @@ -16,9 +23,28 @@ import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; +export const USES_KEY_CONNECTOR = new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "usesKeyConnector", + { + deserializer: (usesKeyConnector) => usesKeyConnector, + clearOn: ["logout"], + }, +); + +export const CONVERT_ACCOUNT_TO_KEY_CONNECTOR = new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "convertAccountToKeyConnector", + { + deserializer: (convertAccountToKeyConnector) => convertAccountToKeyConnector, + clearOn: ["logout"], + }, +); + export class KeyConnectorService implements KeyConnectorServiceAbstraction { + private usesKeyConnectorState: ActiveUserState; + private convertAccountToKeyConnectorState: ActiveUserState; constructor( - private stateService: StateService, private cryptoService: CryptoService, private apiService: ApiService, private tokenService: TokenService, @@ -26,14 +52,20 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private organizationService: OrganizationService, private keyGenerationService: KeyGenerationService, private logoutCallback: (expired: boolean, userId?: string) => Promise, - ) {} - - setUsesKeyConnector(usesKeyConnector: boolean) { - return this.stateService.setUsesKeyConnector(usesKeyConnector); + private stateProvider: StateProvider, + ) { + this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR); + this.convertAccountToKeyConnectorState = this.stateProvider.getActive( + CONVERT_ACCOUNT_TO_KEY_CONNECTOR, + ); } - async getUsesKeyConnector(): Promise { - return await this.stateService.getUsesKeyConnector(); + async setUsesKeyConnector(usesKeyConnector: boolean) { + await this.usesKeyConnectorState.update(() => usesKeyConnector); + } + + getUsesKeyConnector(): Promise { + return firstValueFrom(this.usesKeyConnectorState.state$); } async userNeedsMigration() { @@ -132,19 +164,15 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { } async setConvertAccountRequired(status: boolean) { - await this.stateService.setConvertAccountToKeyConnector(status); + await this.convertAccountToKeyConnectorState.update(() => status); } - async getConvertAccountRequired(): Promise { - return await this.stateService.getConvertAccountToKeyConnector(); + getConvertAccountRequired(): Promise { + return firstValueFrom(this.convertAccountToKeyConnectorState.state$); } async removeConvertAccountRequired() { - await this.stateService.setConvertAccountToKeyConnector(null); - } - - async clear() { - await this.removeConvertAccountRequired(); + await this.setConvertAccountRequired(null); } private handleKeyConnectorError(e: any) { diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index b4847279c33..0ca0615380e 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -52,8 +52,6 @@ export abstract class StateService { setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; - getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise; - setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise; /** * Gets the user's master key */ @@ -269,8 +267,6 @@ export abstract class StateService { getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; getUserId: (options?: StorageOptions) => Promise; - getUsesKeyConnector: (options?: StorageOptions) => Promise; - setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise; getVaultTimeout: (options?: StorageOptions) => Promise; setVaultTimeout: (value: number, options?: StorageOptions) => Promise; getVaultTimeoutAction: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index d01e9d5b8df..01660006c0d 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -158,7 +158,6 @@ export class AccountKeys { } export class AccountProfile { - convertAccountToKeyConnector?: boolean; name?: string; email?: string; emailVerified?: boolean; @@ -166,7 +165,6 @@ export class AccountProfile { forceSetPasswordReason?: ForceSetPasswordReason; lastSync?: string; userId?: string; - usesKeyConnector?: boolean; keyHash?: string; kdfIterations?: number; kdfMemory?: number; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index bbcc00e5629..8c98cc346f0 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -293,23 +293,6 @@ export class StateService< ); } - async getConvertAccountToKeyConnector(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.convertAccountToKeyConnector; - } - - async setConvertAccountToKeyConnector(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.convertAccountToKeyConnector = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - /** * @deprecated Do not save the Master Key. Use the User Symmetric Key instead */ @@ -1298,23 +1281,6 @@ export class StateService< )?.profile?.userId; } - async getUsesKeyConnector(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.usesKeyConnector; - } - - async setUsesKeyConnector(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.usesKeyConnector = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getVaultTimeout(options?: StorageOptions): Promise { const accountVaultTimeout = ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index b44c449c217..35714ee7c4a 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -35,6 +35,7 @@ export const BILLING_DISK = new StateDefinition("billing", "disk"); // Auth +export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index b932a7186ee..60bd31d0498 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -46,6 +46,7 @@ import { MoveDesktopSettingsMigrator } from "./migrations/47-move-desktop-settin import { MoveDdgToStateProviderMigrator } from "./migrations/48-move-ddg-to-state-provider"; import { AccountServerConfigMigrator } from "./migrations/49-move-account-server-configs"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; +import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -53,7 +54,8 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 49; +export const CURRENT_VERSION = 50; + export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -104,7 +106,8 @@ export function createMigrationBuilder() { .with(DeleteBiometricPromptCancelledData, 45, 46) .with(MoveDesktopSettingsMigrator, 46, 47) .with(MoveDdgToStateProviderMigrator, 47, 48) - .with(AccountServerConfigMigrator, 48, CURRENT_VERSION); + .with(AccountServerConfigMigrator, 48, 49) + .with(KeyConnectorMigrator, 49, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts new file mode 100644 index 00000000000..2b960808215 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts @@ -0,0 +1,174 @@ +import { MockProxy } from "jest-mock-extended"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { KeyConnectorMigrator } from "./50-move-key-connector-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + FirstAccount: { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + SecondAccount: { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + user_FirstAccount_keyConnector_usesKeyConnector: true, + user_FirstAccount_keyConnector_convertAccountToKeyConnector: false, + user_SecondAccount_keyConnector_usesKeyConnector: true, + user_SecondAccount_keyConnector_convertAccountToKeyConnector: true, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + FirstAccount: { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + SecondAccount: { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +const usesKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "usesKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "convertAccountToKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +describe("KeyConnectorMigrator", () => { + let helper: MockProxy; + let sut: KeyConnectorMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 50); + sut = new KeyConnectorMigrator(49, 50); + }); + + it("should remove usesKeyConnector and convertAccountToKeyConnector from Profile", async () => { + await sut.migrate(helper); + + // Set is called 2 times even though there are 3 accounts. Since the target properties don't exist in ThirdAccount, they are not set. + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + usesKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + convertAccountToKeyConnectorKeyDefinition, + false, + ); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + usesKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + convertAccountToKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount"); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 50); + sut = new KeyConnectorMigrator(49, 50); + }); + + it("should null out new usesKeyConnector global value", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(4); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + usesKeyConnectorKeyDefinition, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + convertAccountToKeyConnectorKeyDefinition, + null, + ); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + usesKeyConnectorKeyDefinition, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + convertAccountToKeyConnectorKeyDefinition, + null, + ); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount"); + expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount"); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts new file mode 100644 index 00000000000..0deb7d5e2c0 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts @@ -0,0 +1,78 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + profile?: { + usesKeyConnector?: boolean; + convertAccountToKeyConnector?: boolean; + }; +}; + +const usesKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "usesKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "convertAccountToKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +export class KeyConnectorMigrator extends Migrator<49, 50> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const usesKeyConnector = account?.profile?.usesKeyConnector; + const convertAccountToKeyConnector = account?.profile?.convertAccountToKeyConnector; + if (usesKeyConnector == null && convertAccountToKeyConnector == null) { + return; + } + if (usesKeyConnector != null) { + await helper.setToUser(userId, usesKeyConnectorKeyDefinition, usesKeyConnector); + delete account.profile.usesKeyConnector; + } + if (convertAccountToKeyConnector != null) { + await helper.setToUser( + userId, + convertAccountToKeyConnectorKeyDefinition, + convertAccountToKeyConnector, + ); + delete account.profile.convertAccountToKeyConnector; + } + await helper.set(userId, account); + } + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const usesKeyConnector: boolean = await helper.getFromUser( + userId, + usesKeyConnectorKeyDefinition, + ); + const convertAccountToKeyConnector: boolean = await helper.getFromUser( + userId, + convertAccountToKeyConnectorKeyDefinition, + ); + if (usesKeyConnector == null && convertAccountToKeyConnector == null) { + return; + } + if (usesKeyConnector != null) { + account.profile.usesKeyConnector = usesKeyConnector; + await helper.setToUser(userId, usesKeyConnectorKeyDefinition, null); + } + if (convertAccountToKeyConnector != null) { + account.profile.convertAccountToKeyConnector = convertAccountToKeyConnector; + await helper.setToUser(userId, convertAccountToKeyConnectorKeyDefinition, null); + } + await helper.set(userId, account); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +}