1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +00:00

PM-3585 Improve state migrations (#5009)

* WIP: safer state migrations

Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com>

* Add min version check and remove old migrations

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Add rollback and version checking

* Add state version move migration

* Expand tests and improve typing for Migrations

* Remove StateMigration Service

* Rewrite version 5 and 6 migrations

* Add all but initial migration to supported migrations

* Handle stateVersion location in migrator update versions

* Move to unique migrations directory

* Disallow imports outside of state-migrations

* Lint and test fixes

* Do not run migrations if we cannot determine state

* Fix desktop background StateService build

* Document Migration builder class

* Add debug logging to migrations

* Comment on migrator overrides

* Use specific property names

* `npm run prettier` 🤖

* Insert new migration

* Set stateVersion when creating new globals object

* PR comments

* Fix migrate imports

* Move migration building into `migrate` function

* Export current version from migration definitions

* Move file version concerns to migrator

* Update migrate spec to reflect new version requirements

* Fix import paths

* Prefer unique state data

* Remove unnecessary async

* Prefer to not use `any`

---------

Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2023-08-30 12:57:20 -05:00
committed by GitHub
parent b444eed0b5
commit 3340af8084
51 changed files with 1538 additions and 980 deletions

View File

@@ -1,4 +0,0 @@
export abstract class StateMigrationService {
needsMigration: () => Promise<boolean>;
migrate: () => Promise<void>;
}

View File

@@ -495,8 +495,6 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
getStateVersion: () => Promise<number>;
setStateVersion: (value: number) => Promise<void>;
getWindow: () => Promise<WindowState>;
setWindow: (value: WindowState) => Promise<void>;
/**

View File

@@ -1,5 +1,5 @@
import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
import { StateVersion, ThemeType } from "../../../enums";
import { ThemeType } from "../../../enums";
import { WindowState } from "../../../models/domain/window-state";
export class GlobalState {
@@ -25,7 +25,6 @@ export class GlobalState {
enableBiometrics?: boolean;
biometricText?: string;
noAutoPromptBiometricsText?: string;
stateVersion: StateVersion = StateVersion.One;
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
enableTray?: boolean;
enableMinimizeToTray?: boolean;

View File

@@ -1,216 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { MockProxy, any, mock } from "jest-mock-extended";
import { StateVersion } from "../../enums";
import { AbstractStorageService } from "../abstractions/storage.service";
import { StateFactory } from "../factories/state-factory";
import { Account } from "../models/domain/account";
import { GlobalState } from "../models/domain/global-state";
import { StateMigrationService } from "./state-migration.service";
const userId = "USER_ID";
// Note: each test calls the private migration method for that migration,
// so that we don't accidentally run all following migrations as well
describe("State Migration Service", () => {
let storageService: MockProxy<AbstractStorageService>;
let secureStorageService: SubstituteOf<AbstractStorageService>;
let stateFactory: SubstituteOf<StateFactory>;
let stateMigrationService: StateMigrationService;
beforeEach(() => {
storageService = mock();
secureStorageService = Substitute.for<AbstractStorageService>();
stateFactory = Substitute.for<StateFactory>();
stateMigrationService = new StateMigrationService(
storageService,
secureStorageService,
stateFactory
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("StateVersion 3 to 4 migration", () => {
beforeEach(() => {
const globalVersion3: Partial<GlobalState> = {
stateVersion: StateVersion.Three,
};
storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3);
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
});
it("clears everBeenUnlocked", async () => {
const accountVersion3: Account = {
profile: {
apiKeyClientId: null,
convertAccountToKeyConnector: null,
email: "EMAIL",
emailVerified: true,
everBeenUnlocked: true,
hasPremiumPersonally: false,
kdfIterations: 100000,
kdfType: 0,
keyHash: "KEY_HASH",
lastSync: "LAST_SYNC",
userId: userId,
usesKeyConnector: false,
forcePasswordResetReason: null,
},
};
const expectedAccountVersion4: Account = {
profile: {
...accountVersion3.profile,
},
};
delete expectedAccountVersion4.profile.everBeenUnlocked;
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3);
await (stateMigrationService as any).migrateStateFrom3To4();
expect(storageService.save).toHaveBeenCalledTimes(2);
expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any());
});
it("updates StateVersion number", async () => {
await (stateMigrationService as any).migrateStateFrom3To4();
expect(storageService.save).toHaveBeenCalledWith(
"global",
{ stateVersion: StateVersion.Four },
any()
);
expect(storageService.save).toHaveBeenCalledTimes(1);
});
});
describe("StateVersion 4 to 5 migration", () => {
it("migrates organization keys to new format", async () => {
const accountVersion4 = new Account({
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
orgThreeId: "orgThreeEncKey",
},
},
},
} as any);
const expectedAccount = new Account({
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
orgThreeId: {
type: "organization",
key: "orgThreeEncKey",
},
},
} as any,
} as any,
});
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
accountVersion4
);
expect(migratedAccount).toEqual(expectedAccount);
});
});
describe("StateVersion 5 to 6 migration", () => {
it("deletes account.keys.legacyEtmKey value", async () => {
const accountVersion5 = new Account({
keys: {
legacyEtmKey: "legacy key",
},
} as any);
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6(
accountVersion5
);
expect(migratedAccount.keys.legacyEtmKey).toBeUndefined();
});
});
describe("StateVersion 6 to 7 migration", () => {
it("should delete global.noAutoPromptBiometrics value", async () => {
storageService.get
.calledWith("global", any())
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]);
await stateMigrationService.migrate();
expect(storageService.save).toHaveBeenCalledWith(
"global",
{
stateVersion: StateVersion.Seven,
},
any()
);
});
it("should call migrateStateFrom6To7 on each account", async () => {
const accountVersion6 = new Account({
otherStuff: "other stuff",
} as any);
storageService.get
.calledWith("global", any())
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6);
const migrateSpy = jest.fn();
(stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy;
await stateMigrationService.migrate();
expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6);
});
it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => {
const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, {
otherStuff: "other stuff",
});
expect(result).toEqual({
otherStuff: "other stuff",
settings: {
disableAutoBiometricsPrompt: true,
},
});
});
it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => {
const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, {
otherStuff: "other stuff",
});
expect(result).toEqual({
otherStuff: "other stuff",
});
});
});
});

View File

@@ -1,587 +0,0 @@
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { PolicyData } from "../../admin-console/models/data/policy.data";
import { ProviderData } from "../../admin-console/models/data/provider.data";
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
import { TokenService } from "../../auth/services/token.service";
import { StateVersion, ThemeType, KdfType, HtmlStorageLocation } from "../../enums";
import { EventData } from "../../models/data/event.data";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
import { SendData } from "../../tools/send/models/data/send.data";
import { CipherData } from "../../vault/models/data/cipher.data";
import { CollectionData } from "../../vault/models/data/collection.data";
import { FolderData } from "../../vault/models/data/folder.data";
import { AbstractStorageService } from "../abstractions/storage.service";
import { StateFactory } from "../factories/state-factory";
import {
Account,
AccountSettings,
EncryptionPair,
AccountSettingsSettings,
} from "../models/domain/account";
import { EncString } from "../models/domain/enc-string";
import { GlobalState } from "../models/domain/global-state";
import { StorageOptions } from "../models/domain/storage-options";
// Originally (before January 2022) storage was handled as a flat key/value pair store.
// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration.
const v1Keys: { [key: string]: string } = {
accessToken: "accessToken",
alwaysShowDock: "alwaysShowDock",
autoConfirmFingerprints: "autoConfirmFingerprints",
autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault",
biometricAwaitingAcceptance: "biometricAwaitingAcceptance",
biometricFingerprintValidated: "biometricFingerprintValidated",
biometricText: "biometricText",
biometricUnlock: "biometric",
clearClipboard: "clearClipboardKey",
clientId: "apikey_clientId",
clientSecret: "apikey_clientSecret",
collapsedGroupings: "collapsedGroupings",
convertAccountToKeyConnector: "convertAccountToKeyConnector",
defaultUriMatch: "defaultUriMatch",
disableAddLoginNotification: "disableAddLoginNotification",
disableAutoBiometricsPrompt: "noAutoPromptBiometrics",
disableAutoTotpCopy: "disableAutoTotpCopy",
disableBadgeCounter: "disableBadgeCounter",
disableChangedPasswordNotification: "disableChangedPasswordNotification",
disableContextMenuItem: "disableContextMenuItem",
disableFavicon: "disableFavicon",
disableGa: "disableGa",
dontShowCardsCurrentTab: "dontShowCardsCurrentTab",
dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab",
emailVerified: "emailVerified",
enableAlwaysOnTop: "enableAlwaysOnTopKey",
enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad",
enableBiometric: "enabledBiometric",
enableBrowserIntegration: "enableBrowserIntegration",
enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint",
enableCloseToTray: "enableCloseToTray",
enableFullWidth: "enableFullWidth",
enableMinimizeToTray: "enableMinimizeToTray",
enableStartToTray: "enableStartToTrayKey",
enableTray: "enableTray",
encKey: "encKey", // Generated Symmetric Key
encOrgKeys: "encOrgKeys",
encPrivate: "encPrivateKey",
encProviderKeys: "encProviderKeys",
entityId: "entityId",
entityType: "entityType",
environmentUrls: "environmentUrls",
equivalentDomains: "equivalentDomains",
eventCollection: "eventCollection",
forcePasswordReset: "forcePasswordReset",
history: "generatedPasswordHistory",
installedVersion: "installedVersion",
kdf: "kdf",
kdfIterations: "kdfIterations",
key: "key", // Master Key
keyHash: "keyHash",
lastActive: "lastActive",
localData: "sitesLocalData",
locale: "locale",
mainWindowSize: "mainWindowSize",
minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey",
neverDomains: "neverDomains",
noAutoPromptBiometricsText: "noAutoPromptBiometricsText",
openAtLogin: "openAtLogin",
passwordGenerationOptions: "passwordGenerationOptions",
pinProtected: "pinProtectedKey",
protectedPin: "protectedPin",
refreshToken: "refreshToken",
ssoCodeVerifier: "ssoCodeVerifier",
ssoIdentifier: "ssoOrgIdentifier",
ssoState: "ssoState",
stamp: "securityStamp",
theme: "theme",
userEmail: "userEmail",
userId: "userId",
usesConnector: "usesKeyConnector",
vaultTimeoutAction: "vaultTimeoutAction",
vaultTimeout: "lockOption",
rememberedEmail: "rememberedEmail",
};
const v1KeyPrefixes: { [key: string]: string } = {
ciphers: "ciphers_",
collections: "collections_",
folders: "folders_",
lastSync: "lastSync_",
policies: "policies_",
twoFactorToken: "twoFactorToken_",
organizations: "organizations_",
providers: "providers_",
sends: "sends_",
settings: "settings_",
};
const keys = {
global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
accountActivity: "accountActivity",
};
const partialKeys = {
autoKey: "_masterkey_auto",
biometricKey: "_masterkey_biometric",
masterKey: "_masterkey",
};
export class StateMigrationService<
TGlobalState extends GlobalState = GlobalState,
TAccount extends Account = Account
> {
constructor(
protected storageService: AbstractStorageService,
protected secureStorageService: AbstractStorageService,
protected stateFactory: StateFactory<TGlobalState, TAccount>
) {}
async needsMigration(): Promise<boolean> {
const currentStateVersion = await this.getCurrentStateVersion();
return currentStateVersion == null || currentStateVersion < StateVersion.Latest;
}
async migrate(): Promise<void> {
let currentStateVersion = await this.getCurrentStateVersion();
while (currentStateVersion < StateVersion.Latest) {
switch (currentStateVersion) {
case StateVersion.One:
await this.migrateStateFrom1To2();
break;
case StateVersion.Two:
await this.migrateStateFrom2To3();
break;
case StateVersion.Three:
await this.migrateStateFrom3To4();
break;
case StateVersion.Four: {
const authenticatedAccounts = await this.getAuthenticatedAccounts();
for (const account of authenticatedAccounts) {
const migratedAccount = await this.migrateAccountFrom4To5(account);
await this.set(account.profile.userId, migratedAccount);
}
await this.setCurrentStateVersion(StateVersion.Five);
break;
}
case StateVersion.Five: {
const authenticatedAccounts = await this.getAuthenticatedAccounts();
for (const account of authenticatedAccounts) {
const migratedAccount = await this.migrateAccountFrom5To6(account);
await this.set(account.profile.userId, migratedAccount);
}
await this.setCurrentStateVersion(StateVersion.Six);
break;
}
case StateVersion.Six: {
const authenticatedAccounts = await this.getAuthenticatedAccounts();
const globals = (await this.getGlobals()) as any;
for (const account of authenticatedAccounts) {
const migratedAccount = await this.migrateAccountFrom6To7(
globals?.noAutoPromptBiometrics,
account
);
await this.set(account.profile.userId, migratedAccount);
}
if (globals) {
delete globals.noAutoPromptBiometrics;
}
await this.set(keys.global, globals);
await this.setCurrentStateVersion(StateVersion.Seven);
}
}
currentStateVersion += 1;
}
}
protected async migrateStateFrom1To2(): Promise<void> {
const clearV1Keys = async (clearingUserId?: string) => {
for (const key in v1Keys) {
if (key == null) {
continue;
}
await this.set(v1Keys[key], null);
}
if (clearingUserId != null) {
for (const keyPrefix in v1KeyPrefixes) {
if (keyPrefix == null) {
continue;
}
await this.set(v1KeyPrefixes[keyPrefix] + userId, null);
}
}
};
// Some processes, like biometrics, may have already defined a value before migrations are run.
// We don't want to null out those values if they don't exist in the old storage scheme (like for new installs)
// So, the OOO for migration is that we:
// 1. Check for an existing storage value from the old storage structure OR
// 2. Check for a value already set by processes that run before migration OR
// 3. Assign the default value
const globals: any =
(await this.get<GlobalState>(keys.global)) ?? this.stateFactory.createGlobal(null);
globals.stateVersion = StateVersion.Two;
globals.environmentUrls =
(await this.get<EnvironmentUrls>(v1Keys.environmentUrls)) ?? globals.environmentUrls;
globals.locale = (await this.get<string>(v1Keys.locale)) ?? globals.locale;
globals.noAutoPromptBiometrics =
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
globals.noAutoPromptBiometrics;
globals.noAutoPromptBiometricsText =
(await this.get<string>(v1Keys.noAutoPromptBiometricsText)) ??
globals.noAutoPromptBiometricsText;
globals.ssoCodeVerifier =
(await this.get<string>(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier;
globals.ssoOrganizationIdentifier =
(await this.get<string>(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier;
globals.ssoState = (await this.get<any>(v1Keys.ssoState)) ?? globals.ssoState;
globals.rememberedEmail =
(await this.get<string>(v1Keys.rememberedEmail)) ?? globals.rememberedEmail;
globals.theme = (await this.get<ThemeType>(v1Keys.theme)) ?? globals.theme;
globals.vaultTimeout = (await this.get<number>(v1Keys.vaultTimeout)) ?? globals.vaultTimeout;
globals.vaultTimeoutAction =
(await this.get<string>(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction;
globals.window = (await this.get<any>(v1Keys.mainWindowSize)) ?? globals.window;
globals.enableTray = (await this.get<boolean>(v1Keys.enableTray)) ?? globals.enableTray;
globals.enableMinimizeToTray =
(await this.get<boolean>(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray;
globals.enableCloseToTray =
(await this.get<boolean>(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray;
globals.enableStartToTray =
(await this.get<boolean>(v1Keys.enableStartToTray)) ?? globals.enableStartToTray;
globals.openAtLogin = (await this.get<boolean>(v1Keys.openAtLogin)) ?? globals.openAtLogin;
globals.alwaysShowDock =
(await this.get<boolean>(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock;
globals.enableBrowserIntegration =
(await this.get<boolean>(v1Keys.enableBrowserIntegration)) ??
globals.enableBrowserIntegration;
globals.enableBrowserIntegrationFingerprint =
(await this.get<boolean>(v1Keys.enableBrowserIntegrationFingerprint)) ??
globals.enableBrowserIntegrationFingerprint;
const userId =
(await this.get<string>(v1Keys.userId)) ?? (await this.get<string>(v1Keys.entityId));
const defaultAccount = this.stateFactory.createAccount(null);
const accountSettings: AccountSettings = {
autoConfirmFingerPrints:
(await this.get<boolean>(v1Keys.autoConfirmFingerprints)) ??
defaultAccount.settings.autoConfirmFingerPrints,
autoFillOnPageLoadDefault:
(await this.get<boolean>(v1Keys.autoFillOnPageLoadDefault)) ??
defaultAccount.settings.autoFillOnPageLoadDefault,
biometricUnlock:
(await this.get<boolean>(v1Keys.biometricUnlock)) ??
defaultAccount.settings.biometricUnlock,
clearClipboard:
(await this.get<number>(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard,
defaultUriMatch:
(await this.get<any>(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch,
disableAddLoginNotification:
(await this.get<boolean>(v1Keys.disableAddLoginNotification)) ??
defaultAccount.settings.disableAddLoginNotification,
disableAutoBiometricsPrompt:
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
defaultAccount.settings.disableAutoBiometricsPrompt,
disableAutoTotpCopy:
(await this.get<boolean>(v1Keys.disableAutoTotpCopy)) ??
defaultAccount.settings.disableAutoTotpCopy,
disableBadgeCounter:
(await this.get<boolean>(v1Keys.disableBadgeCounter)) ??
defaultAccount.settings.disableBadgeCounter,
disableChangedPasswordNotification:
(await this.get<boolean>(v1Keys.disableChangedPasswordNotification)) ??
defaultAccount.settings.disableChangedPasswordNotification,
disableContextMenuItem:
(await this.get<boolean>(v1Keys.disableContextMenuItem)) ??
defaultAccount.settings.disableContextMenuItem,
disableGa: (await this.get<boolean>(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa,
dontShowCardsCurrentTab:
(await this.get<boolean>(v1Keys.dontShowCardsCurrentTab)) ??
defaultAccount.settings.dontShowCardsCurrentTab,
dontShowIdentitiesCurrentTab:
(await this.get<boolean>(v1Keys.dontShowIdentitiesCurrentTab)) ??
defaultAccount.settings.dontShowIdentitiesCurrentTab,
enableAlwaysOnTop:
(await this.get<boolean>(v1Keys.enableAlwaysOnTop)) ??
defaultAccount.settings.enableAlwaysOnTop,
enableAutoFillOnPageLoad:
(await this.get<boolean>(v1Keys.enableAutoFillOnPageLoad)) ??
defaultAccount.settings.enableAutoFillOnPageLoad,
enableBiometric:
(await this.get<boolean>(v1Keys.enableBiometric)) ??
defaultAccount.settings.enableBiometric,
enableFullWidth:
(await this.get<boolean>(v1Keys.enableFullWidth)) ??
defaultAccount.settings.enableFullWidth,
environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls,
equivalentDomains:
(await this.get<any>(v1Keys.equivalentDomains)) ??
defaultAccount.settings.equivalentDomains,
minimizeOnCopyToClipboard:
(await this.get<boolean>(v1Keys.minimizeOnCopyToClipboard)) ??
defaultAccount.settings.minimizeOnCopyToClipboard,
neverDomains:
(await this.get<any>(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains,
passwordGenerationOptions:
(await this.get<any>(v1Keys.passwordGenerationOptions)) ??
defaultAccount.settings.passwordGenerationOptions,
pinProtected: Object.assign(new EncryptionPair<string, EncString>(), {
decrypted: null,
encrypted: await this.get<string>(v1Keys.pinProtected),
}),
protectedPin: await this.get<string>(v1Keys.protectedPin),
settings:
userId == null
? null
: await this.get<AccountSettingsSettings>(v1KeyPrefixes.settings + userId),
vaultTimeout:
(await this.get<number>(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout,
vaultTimeoutAction:
(await this.get<string>(v1Keys.vaultTimeoutAction)) ??
defaultAccount.settings.vaultTimeoutAction,
};
// (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth
// (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings
if (userId == null) {
await this.set(keys.tempAccountSettings, accountSettings);
await this.set(keys.global, globals);
await this.set(keys.authenticatedAccounts, []);
await this.set(keys.activeUserId, null);
await clearV1Keys();
return;
}
globals.twoFactorToken = await this.get<string>(v1KeyPrefixes.twoFactorToken + userId);
await this.set(keys.global, globals);
await this.set(userId, {
data: {
addEditCipherInfo: null,
ciphers: {
decrypted: null,
encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId),
},
collapsedGroupings: null,
collections: {
decrypted: null,
encrypted: await this.get<{ [id: string]: CollectionData }>(
v1KeyPrefixes.collections + userId
),
},
eventCollection: await this.get<EventData[]>(v1Keys.eventCollection),
folders: {
decrypted: null,
encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId),
},
localData: null,
organizations: await this.get<{ [id: string]: OrganizationData }>(
v1KeyPrefixes.organizations + userId
),
passwordGenerationHistory: {
decrypted: null,
encrypted: await this.get<GeneratedPasswordHistory[]>(v1Keys.history),
},
policies: {
decrypted: null,
encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId),
},
providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId),
sends: {
decrypted: null,
encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId),
},
},
keys: {
apiKeyClientSecret: await this.get<string>(v1Keys.clientSecret),
cryptoMasterKey: null,
cryptoMasterKeyAuto: null,
cryptoMasterKeyB64: null,
cryptoMasterKeyBiometric: null,
cryptoSymmetricKey: {
encrypted: await this.get<string>(v1Keys.encKey),
decrypted: null,
},
legacyEtmKey: null,
organizationKeys: {
decrypted: null,
encrypted: await this.get<any>(v1Keys.encOrgKeys),
},
privateKey: {
decrypted: null,
encrypted: await this.get<string>(v1Keys.encPrivate),
},
providerKeys: {
decrypted: null,
encrypted: await this.get<any>(v1Keys.encProviderKeys),
},
publicKey: null,
},
profile: {
apiKeyClientId: await this.get<string>(v1Keys.clientId),
authenticationStatus: null,
convertAccountToKeyConnector: await this.get<boolean>(v1Keys.convertAccountToKeyConnector),
email: await this.get<string>(v1Keys.userEmail),
emailVerified: await this.get<boolean>(v1Keys.emailVerified),
entityId: null,
entityType: null,
everBeenUnlocked: null,
forcePasswordReset: null,
hasPremiumPersonally: null,
kdfIterations: await this.get<number>(v1Keys.kdfIterations),
kdfType: await this.get<KdfType>(v1Keys.kdf),
keyHash: await this.get<string>(v1Keys.keyHash),
lastSync: null,
userId: userId,
usesKeyConnector: null,
},
settings: accountSettings,
tokens: {
accessToken: await this.get<string>(v1Keys.accessToken),
decodedToken: null,
refreshToken: await this.get<string>(v1Keys.refreshToken),
securityStamp: null,
},
});
await this.set(keys.authenticatedAccounts, [userId]);
await this.set(keys.activeUserId, userId);
const accountActivity: { [userId: string]: number } = {
[userId]: await this.get<number>(v1Keys.lastActive),
};
accountActivity[userId] = await this.get<number>(v1Keys.lastActive);
await this.set(keys.accountActivity, accountActivity);
await clearV1Keys(userId);
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) {
await this.secureStorageService.save(
`${userId}${partialKeys.biometricKey}`,
await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }),
{ keySuffix: "biometric" }
);
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" });
}
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) {
await this.secureStorageService.save(
`${userId}${partialKeys.autoKey}`,
await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }),
{ keySuffix: "auto" }
);
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" });
}
if (await this.secureStorageService.has(v1Keys.key)) {
await this.secureStorageService.save(
`${userId}${partialKeys.masterKey}`,
await this.secureStorageService.get(v1Keys.key)
);
await this.secureStorageService.remove(v1Keys.key);
}
}
protected async migrateStateFrom2To3(): Promise<void> {
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
await Promise.all(
authenticatedUserIds.map(async (userId) => {
const account = await this.get<TAccount>(userId);
if (
account?.profile?.hasPremiumPersonally === null &&
account.tokens?.accessToken != null
) {
const decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
account.profile.hasPremiumPersonally = decodedToken.premium;
await this.set(userId, account);
}
})
);
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Three;
await this.set(keys.global, globals);
}
protected async migrateStateFrom3To4(): Promise<void> {
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
await Promise.all(
authenticatedUserIds.map(async (userId) => {
const account = await this.get<TAccount>(userId);
if (account?.profile?.everBeenUnlocked != null) {
delete account.profile.everBeenUnlocked;
return this.set(userId, account);
}
})
);
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Four;
await this.set(keys.global, globals);
}
protected async migrateAccountFrom4To5(account: TAccount): Promise<TAccount> {
const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys != null) {
for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) {
encryptedOrgKeys[orgId] = {
type: "organization",
key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast
};
}
}
return account;
}
protected async migrateAccountFrom5To6(account: TAccount): Promise<TAccount> {
delete (account as any).keys?.legacyEtmKey;
return account;
}
protected async migrateAccountFrom6To7(
globalSetting: boolean,
account: TAccount
): Promise<TAccount> {
if (globalSetting) {
account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
}
return account;
}
protected get options(): StorageOptions {
return { htmlStorageLocation: HtmlStorageLocation.Local };
}
protected get<T>(key: string): Promise<T> {
return this.storageService.get<T>(key, this.options);
}
protected set(key: string, value: any): Promise<any> {
if (value == null) {
return this.storageService.remove(key, this.options);
}
return this.storageService.save(key, value, this.options);
}
protected async getGlobals(): Promise<TGlobalState> {
return await this.get<TGlobalState>(keys.global);
}
protected async getCurrentStateVersion(): Promise<StateVersion> {
return (await this.getGlobals())?.stateVersion ?? StateVersion.One;
}
protected async setCurrentStateVersion(newVersion: StateVersion): Promise<void> {
const globals = await this.getGlobals();
globals.stateVersion = newVersion;
await this.set(keys.global, globals);
}
protected async getAuthenticatedAccounts(): Promise<TAccount[]> {
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
return Promise.all(authenticatedUserIds.map((id) => this.get<TAccount>(id)));
}
}

View File

@@ -21,6 +21,7 @@ import {
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
import { migrate } from "../../state-migrations";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view";
@@ -32,7 +33,6 @@ import { CipherView } from "../../vault/models/view/cipher.view";
import { CollectionView } from "../../vault/models/view/collection.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { LogService } from "../abstractions/log.service";
import { StateMigrationService } from "../abstractions/state-migration.service";
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
import {
AbstractMemoryStorageService,
@@ -61,6 +61,7 @@ import {
const keys = {
state: "state",
stateVersion: "stateVersion",
global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
@@ -106,7 +107,6 @@ export class StateService<
protected secureStorageService: AbstractStorageService,
protected memoryStorageService: AbstractMemoryStorageService,
protected logService: LogService,
protected stateMigrationService: StateMigrationService,
protected stateFactory: StateFactory<TGlobalState, TAccount>,
protected useAccountCache: boolean = true
) {
@@ -133,9 +133,7 @@ export class StateService<
return;
}
if (await this.stateMigrationService.needsMigration()) {
await this.stateMigrationService.migrate();
}
await migrate(this.storageService, this.logService);
await this.state().then(async (state) => {
if (state == null) {
@@ -2724,16 +2722,6 @@ export class StateService<
);
}
async getStateVersion(): Promise<number> {
return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
}
async setStateVersion(value: number): Promise<void> {
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
globals.stateVersion = value;
await this.saveGlobals(globals, await this.defaultOnDiskOptions());
}
async getWindow(): Promise<WindowState> {
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
return globals?.window != null && Object.keys(globals.window).length > 0
@@ -2838,7 +2826,11 @@ export class StateService<
globals = await this.getGlobalsFromDisk(options);
}
return globals ?? this.createGlobals();
if (globals == null) {
globals = this.createGlobals();
}
return globals;
}
protected async saveGlobals(globals: TGlobalState, options: StorageOptions) {