[PM-5269] Key Connector state migration (#8327)

* key connector migration initial

* migrator complete

* fix dependencies

* finalized tests

* fix deps and sync main

* clean up definition file

* fixing tests

* fixed tests

* fixing CLI, Browser, Desktop builds

* fixed factory options

* reverting exports

* implemented UserKeyDefinition clearOn

* Update KeyConnector MIgration

* updated migrator and tests to match profile object

* removed unused service and updated clear

* dep fix

* dep fixes

* clear usesKeyConnector on logout
This commit is contained in:
Ike 2024-03-28 09:50:24 -07:00 committed by GitHub
parent df058ba399
commit 3d19e3489c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 691 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,5 +15,4 @@ export abstract class KeyConnectorService {
setConvertAccountRequired: (status: boolean) => Promise<void>;
getConvertAccountRequired: () => Promise<boolean>;
removeConvertAccountRequired: () => Promise<void>;
clear: () => Promise<void>;
}

View File

@ -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<CryptoService>();
const apiService = mock<ApiService>();
const tokenService = mock<TokenService>();
const logService = mock<LogService>();
const organizationService = mock<OrganizationService>();
const keyGenerationService = mock<KeyGenerationService>();
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;
}
});

View File

@ -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<boolean>(
KEY_CONNECTOR_DISK,
"usesKeyConnector",
{
deserializer: (usesKeyConnector) => usesKeyConnector,
clearOn: ["logout"],
},
);
export const CONVERT_ACCOUNT_TO_KEY_CONNECTOR = new UserKeyDefinition<boolean>(
KEY_CONNECTOR_DISK,
"convertAccountToKeyConnector",
{
deserializer: (convertAccountToKeyConnector) => convertAccountToKeyConnector,
clearOn: ["logout"],
},
);
export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private usesKeyConnectorState: ActiveUserState<boolean>;
private convertAccountToKeyConnectorState: ActiveUserState<boolean>;
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<void>,
) {}
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<boolean> {
return await this.stateService.getUsesKeyConnector();
async setUsesKeyConnector(usesKeyConnector: boolean) {
await this.usesKeyConnectorState.update(() => usesKeyConnector);
}
getUsesKeyConnector(): Promise<boolean> {
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<boolean> {
return await this.stateService.getConvertAccountToKeyConnector();
getConvertAccountRequired(): Promise<boolean> {
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) {

View File

@ -52,8 +52,6 @@ export abstract class StateService<T extends Account = Account> {
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
* Gets the user's master key
*/
@ -269,8 +267,6 @@ export abstract class StateService<T extends Account = Account> {
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
getUserId: (options?: StorageOptions) => Promise<string>;
getUsesKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
getVaultTimeout: (options?: StorageOptions) => Promise<number>;
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;

View File

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

View File

@ -293,23 +293,6 @@ export class StateService<
);
}
async getConvertAccountToKeyConnector(options?: StorageOptions): Promise<boolean> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.profile?.convertAccountToKeyConnector;
}
async setConvertAccountToKeyConnector(value: boolean, options?: StorageOptions): Promise<void> {
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<boolean> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.profile?.usesKeyConnector;
}
async setUsesKeyConnector(value: boolean, options?: StorageOptions): Promise<void> {
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<number> {
const accountVaultTimeout = (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))

View File

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

View File

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

View File

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

View File

@ -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<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
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<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
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))]);
}
}