1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 20:50:28 +00:00

Merge branch 'master' into PM-2014-passkey-registration

This commit is contained in:
Andreas Coroiu
2023-09-26 13:12:00 +02:00
554 changed files with 46726 additions and 10565 deletions

View File

@@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export function canAccessVaultTab(org: Organization): boolean {
return org.canViewAssignedCollections || org.canViewAllCollections || org.canManageGroups;
return org.canViewAssignedCollections || org.canViewAllCollections;
}
export function canAccessSettingsTab(org: Organization): boolean {

View File

@@ -174,7 +174,7 @@ export class Organization {
}
get canViewAllCollections() {
return this.canCreateNewCollections || this.canEditAnyCollection || this.canDeleteAnyCollection;
return this.canEditAnyCollection || this.canDeleteAnyCollection;
}
get canEditAssignedCollections() {

View File

@@ -41,10 +41,7 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
import {
IUserDecryptionOptionsServerResponse,
UserDecryptionOptionsResponse,
} from "../models/response/user-decryption-options/user-decryption-options.response";
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
import { PasswordLogInStrategy } from "./password-login.strategy";
@@ -65,10 +62,6 @@ const name = "NAME";
const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: true,
};
const userDecryptionOptions = new UserDecryptionOptionsResponse(
defaultUserDecryptionOptionsServerResponse
);
const acctDecryptionOptions = AccountDecryptionOptions.fromResponse(userDecryptionOptions);
const decodedToken = {
sub: userId,
@@ -197,7 +190,7 @@ describe("LogInStrategy", () => {
},
},
keys: new AccountKeys(),
decryptionOptions: acctDecryptionOptions,
decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse),
})
);
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");

View File

@@ -1,4 +1,5 @@
import { ApiService } from "../../abstractions/api.service";
import { ClientType } from "../../enums";
import { KeysRequest } from "../../models/request/keys.request";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
@@ -143,9 +144,7 @@ export abstract class LogInStrategy {
},
},
keys: accountKeys,
decryptionOptions: AccountDecryptionOptions.fromResponse(
tokenResponse.userDecryptionOptions
),
decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse),
adminAuthRequest: adminAuthRequest?.toJSON(),
})
);
@@ -153,8 +152,19 @@ export abstract class LogInStrategy {
protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> {
const result = new AuthResult();
// Old encryption keys must be migrated, but is currently only available on web.
// Other clients shouldn't continue the login process.
if (this.encryptionKeyMigrationRequired(response)) {
result.requiresEncryptionKeyMigration = true;
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
return result;
}
}
result.resetMasterPassword = response.resetMasterPassword;
// Convert boolean to enum
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset;
}
@@ -167,9 +177,7 @@ export abstract class LogInStrategy {
}
await this.setMasterKey(response);
await this.setUserKey(response);
await this.setPrivateKey(response);
this.messagingService.send("loggedIn");
@@ -184,6 +192,12 @@ export abstract class LogInStrategy {
protected abstract setPrivateKey(response: IdentityTokenResponse): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to
// check on password logins
protected encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {
return false;
}
protected async createKeyPairForOldAccount() {
try {
const [publicKey, privateKey] = await this.cryptoService.makeKeyPair();

View File

@@ -147,6 +147,10 @@ export class PasswordLogInStrategy extends LogInStrategy {
}
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
// If migration is required, we won't have a user key to set yet.
if (this.encryptionKeyMigrationRequired(response)) {
return;
}
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
const masterKey = await this.cryptoService.getMasterKey();
@@ -162,6 +166,10 @@ export class PasswordLogInStrategy extends LogInStrategy {
);
}
protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {
return !response.key;
}
private getMasterPasswordPolicyOptionsFromResponse(
response: IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse
): MasterPasswordPolicyOptions {

View File

@@ -266,11 +266,14 @@ describe("SsoLogInStrategy", () => {
describe("Key Connector", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse = identityTokenResponseFactory(null, {
HasMasterPassword: false,
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
});
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled", async () => {
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
@@ -283,7 +286,7 @@ describe("SsoLogInStrategy", () => {
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
});
it("converts new SSO user to Key Connector on first login", async () => {
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
@@ -296,7 +299,58 @@ describe("SsoLogInStrategy", () => {
);
});
it("decrypts and sets the user key if Key Connector is enabled", async () => {
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await ssoLogInStrategy.logIn(credentials);
expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey);
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
});
});
describe("Key Connector Pre-TDE", () => {
let tokenResponse: IdentityTokenResponse;
beforeEach(() => {
tokenResponse = identityTokenResponseFactory();
tokenResponse.userDecryptionOptions = null;
tokenResponse.keyConnectorUrl = keyConnectorUrl;
});
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray
) as MasterKey;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
cryptoService.getMasterKey.mockResolvedValue(masterKey);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
});
it("converts new SSO user with no master password to Key Connector on first login", async () => {
tokenResponse.key = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLogInStrategy.logIn(credentials);
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
tokenResponse,
ssoOrgId
);
});
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
const masterKey = new SymmetricCryptoKey(
new Uint8Array(64).buffer as CsprngArray

View File

@@ -14,6 +14,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trus
import { KeyConnectorService } from "../abstractions/key-connector.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import { SsoLogInCredentials } from "../models/domain/log-in-credentials";
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
@@ -73,23 +74,65 @@ export class SsoLogInStrategy extends LogInStrategy {
this.email = ssoAuthResult.email;
this.ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
// Auth guard currently handles redirects for this.
if (ssoAuthResult.forcePasswordReset == ForceResetPasswordReason.AdminForcePasswordReset) {
await this.stateService.setForcePasswordResetReason(ssoAuthResult.forcePasswordReset);
}
return ssoAuthResult;
}
protected override async setMasterKey(tokenResponse: IdentityTokenResponse) {
// TODO: discuss how this is no longer true with TDE
// eventually well need to support migration of existing TDE users to Key Connector
const newSsoUser = tokenResponse.key == null;
if (tokenResponse.keyConnectorUrl != null) {
if (!newSsoUser) {
await this.keyConnectorService.setMasterKeyFromUrl(tokenResponse.keyConnectorUrl);
} else {
// The only way we can be setting a master key at this point is if we are using Key Connector.
// First, check to make sure that we should do so based on the token response.
if (this.shouldSetMasterKeyFromKeyConnector(tokenResponse)) {
// If we're here, we know that the user should use Key Connector (they have a KeyConnectorUrl) and does not have a master password.
// We can now check the key on the token response to see whether they are a brand new user or an existing user.
// The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector.
const newSsoUser = tokenResponse.key == null;
if (newSsoUser) {
await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId);
} else {
const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse);
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
}
}
}
/**
* Determines if it is possible set the `masterKey` from Key Connector.
* @param tokenResponse
* @returns `true` if the master key can be set from Key Connector, `false` otherwise
*/
private shouldSetMasterKeyFromKeyConnector(tokenResponse: IdentityTokenResponse): boolean {
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
if (userDecryptionOptions != null) {
const userHasMasterPassword = userDecryptionOptions.hasMasterPassword;
const userHasKeyConnectorUrl =
userDecryptionOptions.keyConnectorOption?.keyConnectorUrl != null;
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
// and the user must not have a master password.
return userHasKeyConnectorUrl && !userHasMasterPassword;
} else {
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
// In this case, we can determine if the user has a master password and has a Key Connector URL by
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
// and will not pass back the URL in the response if the user has a master password.
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
return tokenResponse.keyConnectorUrl != null;
}
}
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
return (
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
);
}
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)
// so might be worth moving this logic to a common place (base login strategy or a separate service?)
protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise<void> {
@@ -117,9 +160,8 @@ export class SsoLogInStrategy extends LogInStrategy {
await this.trySetUserKeyWithDeviceKey(tokenResponse);
}
} else if (
// TODO: remove tokenResponse.keyConnectorUrl when it's deprecated
masterKeyEncryptedUserKey != null &&
(tokenResponse.keyConnectorUrl || userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl)
this.getKeyConnectorUrl(tokenResponse) != null
) {
// Key connector enabled for user
await this.trySetUserKeyWithMasterKey();
@@ -208,12 +250,15 @@ export class SsoLogInStrategy extends LogInStrategy {
private async trySetUserKeyWithMasterKey(): Promise<void> {
const masterKey = await this.cryptoService.getMasterKey();
// There is a scenario in which the master key is not set here. That will occur if the user
// has a master password and is using Key Connector. In that case, we cannot set the master key
// because the user hasn't entered their master password yet.
// Instead, we'll return here and let the migration to Key Connector handle setting the master key.
if (!masterKey) {
throw new Error("Master key not found");
return;
}
const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey);
await this.cryptoService.setUserKey(userKey);
}

View File

@@ -17,6 +17,7 @@ export class AuthResult {
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string }> = null;
ssoEmail2FaSessionToken?: string;
email: string;
requiresEncryptionKeyMigration: boolean;
get requiresCaptcha() {
return !Utils.isNullOrWhitespace(this.captchaSiteKey);

View File

@@ -1,3 +1,7 @@
/*
* This enum is used to determine if a user should be forced to reset their password
* on login (server flag) or unlock via MP (client evaluation).
*/
export enum ForceResetPasswordReason {
/**
* A password reset should not be forced.
@@ -6,12 +10,14 @@ export enum ForceResetPasswordReason {
/**
* Occurs when an organization admin forces a user to reset their password.
* Communicated via server flag.
*/
AdminForcePasswordReset,
/**
* Occurs when a user logs in / unlocks their vault with a master password that does not meet an organization's
* master password policy that is enforced on login/unlock.
* Only set client side b/c server can't evaluate MP.
*/
WeakMasterPassword,
}

View File

@@ -167,7 +167,7 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
private async makeDeviceKey(): Promise<DeviceKey> {
// Create 512-bit device key
const randomBytes: CsprngArray = await this.cryptoFunctionService.randomBytes(64);
const randomBytes: CsprngArray = await this.cryptoFunctionService.aesGenerateKey(512);
const deviceKey = new SymmetricCryptoKey(randomBytes) as DeviceKey;
return deviceKey;

View File

@@ -168,16 +168,16 @@ describe("deviceTrustCryptoService", () => {
it("creates a new non-null 64 byte device key, securely stores it, and returns it", async () => {
const mockRandomBytes = new Uint8Array(deviceKeyBytesLength) as CsprngArray;
const cryptoFuncSvcRandomBytesSpy = jest
.spyOn(cryptoFunctionService, "randomBytes")
const cryptoFuncSvcGenerateKeySpy = jest
.spyOn(cryptoFunctionService, "aesGenerateKey")
.mockResolvedValue(mockRandomBytes);
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
const deviceKey = await (deviceTrustCryptoService as any).makeDeviceKey();
expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledTimes(1);
expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledWith(deviceKeyBytesLength);
expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledTimes(1);
expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledWith(deviceKeyBytesLength * 8);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);

View File

@@ -24,7 +24,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private logService: LogService,
private organizationService: OrganizationService,
private cryptoFunctionService: CryptoFunctionService,
private logoutCallback: (expired: boolean, userId?: string) => void
private logoutCallback: (expired: boolean, userId?: string) => Promise<void>
) {}
setUsesKeyConnector(usesKeyConnector: boolean) {
@@ -84,8 +84,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
}
async convertNewSsoUserToKeyConnector(tokenResponse: IdentityTokenResponse, orgId: string) {
const { kdf, kdfIterations, kdfMemory, kdfParallelism, keyConnectorUrl } = tokenResponse;
const password = await this.cryptoFunctionService.randomBytes(64);
// TODO: Remove after tokenResponse.keyConnectorUrl is deprecated in 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const {
kdf,
kdfIterations,
kdfMemory,
kdfParallelism,
keyConnectorUrl: legacyKeyConnectorUrl,
userDecryptionOptions,
} = tokenResponse;
const password = await this.cryptoFunctionService.aesGenerateKey(512);
const kdfConfig = new KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const masterKey = await this.cryptoService.makeMasterKey(
@@ -104,6 +112,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
const [pubKey, privKey] = await this.cryptoService.makeKeyPair();
try {
const keyConnectorUrl =
legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest);
} catch (e) {
this.handleKeyConnectorError(e);

View File

@@ -55,7 +55,7 @@ export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactor
description: null as string,
priority: 4,
sort: 5,
premium: true,
premium: false,
},
};

View File

@@ -2,7 +2,7 @@ export enum ClientType {
Web = "web",
Browser = "browser",
Desktop = "desktop",
Mobile = "mobile",
// Mobile = "mobile",
Cli = "cli",
DirectoryConnector = "connector",
// DirectoryConnector = "connector",
}

View File

@@ -4,4 +4,8 @@ export enum FeatureFlag {
TrustedDeviceEncryption = "trusted-device-encryption",
PasswordlessLogin = "passwordless-login",
SecretsManagerBilling = "sm-ga-billing",
AutofillV2 = "autofill-v2",
}
// Replace this with a type safe lookup of the feature flag values in PM-2282
export type FeatureFlagValue = number | string | boolean;

View File

@@ -18,7 +18,6 @@ export * from "./notification-type.enum";
export * from "./product-type.enum";
export * from "./provider-type.enum";
export * from "./secure-note-type.enum";
export * from "./state-version.enum";
export * from "./storage-location.enum";
export * from "./theme-type.enum";
export * from "./uri-match-type.enum";

View File

@@ -1,10 +0,0 @@
export enum StateVersion {
One = 1, // Original flat key/value pair store
Two = 2, // Move to a typed State object
Three = 3, // Fix migration of users' premium status
Four = 4, // Fix 'Never Lock' option by removing stale data
Five = 5, // Migrate to new storage of encrypted organization keys
Six = 6, // Delete account.keys.legacyEtmKey property
Seven = 7, // Remove global desktop auto prompt setting, move to account
Latest = Seven,
}

View File

@@ -1,5 +1,6 @@
export class ReferenceEventRequest {
id: string;
session: string;
layout: string;
flow: string;
}

View File

@@ -1,14 +1,26 @@
import { Observable } from "rxjs";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
export abstract class ConfigServiceAbstraction {
serverConfig$: Observable<ServerConfig | null>;
fetchServerConfig: () => Promise<ServerConfig>;
getFeatureFlagBool: (key: FeatureFlag, defaultValue?: boolean) => Promise<boolean>;
getFeatureFlagString: (key: FeatureFlag, defaultValue?: string) => Promise<string>;
getFeatureFlagNumber: (key: FeatureFlag, defaultValue?: number) => Promise<number>;
getCloudRegion: (defaultValue?: string) => Promise<string>;
cloudRegion$: Observable<Region>;
getFeatureFlag$: <T extends boolean | number | string>(
key: FeatureFlag,
defaultValue?: T
) => Observable<T>;
getFeatureFlag: <T extends boolean | number | string>(
key: FeatureFlag,
defaultValue?: T
) => Promise<T>;
/**
* Force ConfigService to fetch an updated config from the server and emit it from serverConfig$
* @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from
* server instead
*/
triggerServerConfigFetch: () => void;
}

View File

@@ -66,5 +66,14 @@ export abstract class CryptoFunctionService {
) => Promise<Uint8Array>;
rsaExtractPublicKey: (privateKey: Uint8Array) => Promise<Uint8Array>;
rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[Uint8Array, Uint8Array]>;
/**
* Generates a key of the given length suitable for use in AES encryption
*/
aesGenerateKey: (bitLength: 128 | 192 | 256 | 512) => Promise<CsprngArray>;
/**
* Generates a random array of bytes of the given length. Uses a cryptographically secure random number generator.
*
* Do not use this for generating encryption keys. Use aesGenerateKey or rsaGenerateKeyPair instead.
*/
randomBytes: (length: number) => Promise<CsprngArray>;
}

View File

@@ -42,6 +42,12 @@ export abstract class CryptoService {
* @returns The user key
*/
getUserKey: (userId?: string) => Promise<UserKey>;
/**
* Checks if the user is using an old encryption scheme that used the master key
* for encryption of data instead of the user key.
*/
isLegacyUser: (masterKey?: MasterKey, userId?: string) => Promise<boolean>;
/**
* Use for encryption/decryption of data in order to support legacy
* encryption models. It will return the user key if available,

View File

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

View File

@@ -13,7 +13,9 @@ import { BiometricKey } from "../../auth/types/biometric-key";
import { KdfType, ThemeType, UriMatchType } from "../../enums";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../tools/generator/username";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view";
import { CipherData } from "../../vault/models/data/cipher.data";
@@ -439,12 +441,18 @@ export abstract class StateService<T extends Account = Account> {
value: { [id: string]: OrganizationData },
options?: StorageOptions
) => Promise<void>;
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<any>;
setPasswordGenerationOptions: (value: any, options?: StorageOptions) => Promise<void>;
getUsernameGenerationOptions: (options?: StorageOptions) => Promise<any>;
setUsernameGenerationOptions: (value: any, options?: StorageOptions) => Promise<void>;
getGeneratorOptions: (options?: StorageOptions) => Promise<any>;
setGeneratorOptions: (value: any, options?: StorageOptions) => Promise<void>;
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
setPasswordGenerationOptions: (
value: PasswordGeneratorOptions,
options?: StorageOptions
) => Promise<void>;
getUsernameGenerationOptions: (options?: StorageOptions) => Promise<UsernameGeneratorOptions>;
setUsernameGenerationOptions: (
value: UsernameGeneratorOptions,
options?: StorageOptions
) => Promise<void>;
getGeneratorOptions: (options?: StorageOptions) => Promise<GeneratorOptions>;
setGeneratorOptions: (value: GeneratorOptions, options?: StorageOptions) => Promise<void>;
/**
* Gets the user's Pin, encrypted by the user key
*/
@@ -495,8 +503,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

@@ -32,6 +32,7 @@ export class Utils {
static regexpEmojiPresentation =
/(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])/g;
static readonly validHosts: string[] = ["localhost"];
static readonly originalMinimumPasswordLength = 8;
static readonly minimumPasswordLength = 12;
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
["google.com", new Set(["script.google.com"])],

View File

@@ -11,10 +11,15 @@ import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason";
import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { UserDecryptionOptionsResponse } from "../../../auth/models/response/user-decryption-options/user-decryption-options.response";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { KdfType, UriMatchType } from "../../../enums";
import { EventData } from "../../../models/data/event.data";
import { GeneratedPasswordHistory } from "../../../tools/generator/password";
import { GeneratorOptions } from "../../../tools/generator/generator-options";
import {
GeneratedPasswordHistory,
PasswordGeneratorOptions,
} from "../../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options";
import { SendData } from "../../../tools/send/models/data/send.data";
import { SendView } from "../../../tools/send/models/view/send.view";
import { DeepJsonify } from "../../../types/deep-jsonify";
@@ -235,9 +240,9 @@ export class AccountSettings {
equivalentDomains?: any;
minimizeOnCopyToClipboard?: boolean;
neverDomains?: { [id: string]: any };
passwordGenerationOptions?: any;
usernameGenerationOptions?: any;
generatorOptions?: any;
passwordGenerationOptions?: PasswordGeneratorOptions;
usernameGenerationOptions?: UsernameGeneratorOptions;
generatorOptions?: GeneratorOptions;
pinKeyEncryptedUserKey?: EncryptedString;
pinKeyEncryptedUserKeyEphemeral?: EncryptedString;
protectedPin?: string;
@@ -311,28 +316,46 @@ export class AccountDecryptionOptions {
// return this.keyConnectorOption !== null && this.keyConnectorOption !== undefined;
// }
static fromResponse(response: UserDecryptionOptionsResponse): AccountDecryptionOptions {
static fromResponse(response: IdentityTokenResponse): AccountDecryptionOptions {
if (response == null) {
return null;
}
const accountDecryptionOptions = new AccountDecryptionOptions();
accountDecryptionOptions.hasMasterPassword = response.hasMasterPassword;
if (response.trustedDeviceOption) {
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
response.trustedDeviceOption.hasAdminApproval,
response.trustedDeviceOption.hasLoginApprovingDevice,
response.trustedDeviceOption.hasManageResetPasswordPermission
);
if (response.userDecryptionOptions) {
// If the response has userDecryptionOptions, this means it's on a post-TDE server version and can interrogate
// the new decryption options.
const responseOptions = response.userDecryptionOptions;
accountDecryptionOptions.hasMasterPassword = responseOptions.hasMasterPassword;
if (responseOptions.trustedDeviceOption) {
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
responseOptions.trustedDeviceOption.hasAdminApproval,
responseOptions.trustedDeviceOption.hasLoginApprovingDevice,
responseOptions.trustedDeviceOption.hasManageResetPasswordPermission
);
}
if (responseOptions.keyConnectorOption) {
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
responseOptions.keyConnectorOption.keyConnectorUrl
);
}
} else {
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
// we must base our decryption options on the presence of the keyConnectorUrl.
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
const usingKeyConnector = response.keyConnectorUrl != null;
accountDecryptionOptions.hasMasterPassword = !usingKeyConnector;
if (usingKeyConnector) {
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
response.keyConnectorUrl
);
}
}
if (response.keyConnectorOption) {
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
response.keyConnectorOption.keyConnectorUrl
);
}
return accountDecryptionOptions;
}

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

@@ -0,0 +1,202 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ReplaySubject, skip, take } from "rxjs";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data";
import {
EnvironmentServerConfigResponse,
ServerConfigResponse,
ThirdPartyServerConfigResponse,
} from "../../models/response/server-config.response";
import { ConfigService } from "./config.service";
describe("ConfigService", () => {
let stateService: MockProxy<StateService>;
let configApiService: MockProxy<ConfigApiServiceAbstraction>;
let authService: MockProxy<AuthService>;
let environmentService: MockProxy<EnvironmentService>;
let logService: MockProxy<LogService>;
let serverResponseCount: number; // increments to track distinct responses received from server
// Observables will start emitting as soon as this is created, so only create it
// after everything is mocked
const configServiceFactory = () => {
const configService = new ConfigService(
stateService,
configApiService,
authService,
environmentService,
logService
);
configService.init();
return configService;
};
beforeEach(() => {
stateService = mock();
configApiService = mock();
authService = mock();
environmentService = mock();
logService = mock();
environmentService.urls = new ReplaySubject<void>(1);
serverResponseCount = 1;
configApiService.get.mockImplementation(() =>
Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++))
);
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("Uses storage as fallback", (done) => {
const storedConfigData = serverConfigDataFactory("storedConfig");
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
const configService = configServiceFactory();
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
expect(config).toEqual(new ServerConfig(storedConfigData));
expect(stateService.getServerConfig).toHaveBeenCalledTimes(1);
expect(stateService.setServerConfig).not.toHaveBeenCalled();
done();
});
configService.triggerServerConfigFetch();
});
it("Stream does not error out if fetch fails", (done) => {
const storedConfigData = serverConfigDataFactory("storedConfig");
stateService.getServerConfig.mockResolvedValueOnce(storedConfigData);
const configService = configServiceFactory();
configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => {
try {
expect(config.gitHash).toEqual("server1");
done();
} catch (e) {
done(e);
}
});
configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch"));
configService.triggerServerConfigFetch();
configApiService.get.mockResolvedValueOnce(serverConfigResponseFactory("server1"));
configService.triggerServerConfigFetch();
});
describe("Fetches config from server", () => {
beforeEach(() => {
stateService.getServerConfig.mockResolvedValueOnce(null);
});
it.each<number | jest.DoneCallback>([1, 2, 3])(
"after %p hour/s",
(hours: number, done: jest.DoneCallback) => {
const configService = configServiceFactory();
// skip previous hours (if any)
configService.serverConfig$.pipe(skip(hours - 1), take(1)).subscribe((config) => {
try {
expect(config.gitHash).toEqual("server" + hours);
expect(configApiService.get).toHaveBeenCalledTimes(hours);
done();
} catch (e) {
done(e);
}
});
const oneHourInMs = 1000 * 3600;
jest.advanceTimersByTime(oneHourInMs * hours + 1);
}
);
it("when environment URLs change", (done) => {
const configService = configServiceFactory();
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
try {
expect(config.gitHash).toEqual("server1");
done();
} catch (e) {
done(e);
}
});
(environmentService.urls as ReplaySubject<void>).next();
});
it("when triggerServerConfigFetch() is called", (done) => {
const configService = configServiceFactory();
configService.serverConfig$.pipe(take(1)).subscribe((config) => {
try {
expect(config.gitHash).toEqual("server1");
done();
} catch (e) {
done(e);
}
});
configService.triggerServerConfigFetch();
});
});
it("Saves server config to storage when the user is logged in", (done) => {
stateService.getServerConfig.mockResolvedValueOnce(null);
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked);
const configService = configServiceFactory();
configService.serverConfig$.pipe(take(1)).subscribe(() => {
try {
expect(stateService.setServerConfig).toHaveBeenCalledWith(
expect.objectContaining({ gitHash: "server1" })
);
done();
} catch (e) {
done(e);
}
});
configService.triggerServerConfigFetch();
});
});
function serverConfigDataFactory(gitHash: string) {
return new ServerConfigData(serverConfigResponseFactory(gitHash));
}
function serverConfigResponseFactory(gitHash: string) {
return new ServerConfigResponse({
version: "myConfigVersion",
gitHash: gitHash,
server: new ThirdPartyServerConfigResponse({
name: "myThirdPartyServer",
url: "www.example.com",
}),
environment: new EnvironmentServerConfigResponse({
vault: "vault.example.com",
}),
featureStates: {
feat1: "off",
feat2: "on",
feat3: "off",
},
});
}

View File

@@ -1,103 +1,106 @@
import { BehaviorSubject, concatMap, from, timer } from "rxjs";
import {
ReplaySubject,
Subject,
catchError,
concatMap,
defer,
delayWhen,
firstValueFrom,
map,
merge,
timer,
} from "rxjs";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction";
import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService } from "../../abstractions/environment.service";
import { EnvironmentService, Region } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { StateService } from "../../abstractions/state.service";
import { ServerConfigData } from "../../models/data/server-config.data";
const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600;
export class ConfigService implements ConfigServiceAbstraction {
protected _serverConfig = new BehaviorSubject<ServerConfig | null>(null);
protected _serverConfig = new ReplaySubject<ServerConfig | null>(1);
serverConfig$ = this._serverConfig.asObservable();
private _forceFetchConfig = new Subject<void>();
private inited = false;
cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US)
);
constructor(
private stateService: StateService,
private configApiService: ConfigApiServiceAbstraction,
private authService: AuthService,
private environmentService: EnvironmentService
) {
// Re-fetch the server config every hour
timer(0, 1000 * 3600)
.pipe(concatMap(() => from(this.fetchServerConfig())))
.subscribe((serverConfig) => {
this._serverConfig.next(serverConfig);
});
private environmentService: EnvironmentService,
private logService: LogService,
this.environmentService.urls.subscribe(() => {
this.fetchServerConfig();
});
}
// Used to avoid duplicate subscriptions, e.g. in browser between the background and popup
private subscribe = true
) {}
async fetchServerConfig(): Promise<ServerConfig> {
try {
const response = await this.configApiService.get();
if (response == null) {
return;
}
const data = new ServerConfigData(response);
const serverConfig = new ServerConfig(data);
this._serverConfig.next(serverConfig);
const userAuthStatus = await this.authService.getAuthStatus();
if (userAuthStatus !== AuthenticationStatus.LoggedOut) {
// Store the config for offline use if the user is logged in
await this.stateService.setServerConfig(data);
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
}
// Always return new server config from server to calling method
// to ensure up to date information
// This change is specifically for the getFeatureFlag > buildServerConfig flow
// for locked or logged in users.
return serverConfig;
} catch {
return null;
}
}
async getFeatureFlagBool(key: FeatureFlag, defaultValue = false): Promise<boolean> {
return await this.getFeatureFlag(key, defaultValue);
}
async getFeatureFlagString(key: FeatureFlag, defaultValue = ""): Promise<string> {
return await this.getFeatureFlag(key, defaultValue);
}
async getFeatureFlagNumber(key: FeatureFlag, defaultValue = 0): Promise<number> {
return await this.getFeatureFlag(key, defaultValue);
}
async getCloudRegion(defaultValue = "US"): Promise<string> {
const serverConfig = await this.buildServerConfig();
return serverConfig.environment?.cloudRegion ?? defaultValue;
}
private async getFeatureFlag<T>(key: FeatureFlag, defaultValue: T): Promise<T> {
const serverConfig = await this.buildServerConfig();
if (
serverConfig == null ||
serverConfig.featureStates == null ||
serverConfig.featureStates[key] == null
) {
return defaultValue;
}
return serverConfig.featureStates[key] as T;
}
private async buildServerConfig(): Promise<ServerConfig> {
const data = await this.stateService.getServerConfig();
const domain = data ? new ServerConfig(data) : this._serverConfig.getValue();
if (domain == null || !domain.isValid() || domain.expiresSoon()) {
const value = await this.fetchServerConfig();
return value ?? domain;
init() {
if (!this.subscribe || this.inited) {
return;
}
return domain;
const latestServerConfig$ = defer(() => this.configApiService.get()).pipe(
map((response) => new ServerConfigData(response)),
delayWhen((data) => this.saveConfig(data)),
catchError((e: unknown) => {
// fall back to stored ServerConfig (if any)
this.logService.error("Unable to fetch ServerConfig: " + (e as Error)?.message);
return this.stateService.getServerConfig();
})
);
// If you need to fetch a new config when an event occurs, add an observable that emits on that event here
merge(
timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS), // after 1 hour, then every hour
this.environmentService.urls, // when environment URLs change (including when app is started)
this._forceFetchConfig // manual
)
.pipe(
concatMap(() => latestServerConfig$),
map((data) => (data == null ? null : new ServerConfig(data)))
)
.subscribe((config) => this._serverConfig.next(config));
this.inited = true;
}
getFeatureFlag$<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
return this.serverConfig$.pipe(
map((serverConfig) => {
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
return defaultValue;
}
return serverConfig.featureStates[key] as T;
})
);
}
async getFeatureFlag<T extends FeatureFlagValue>(key: FeatureFlag, defaultValue?: T) {
return await firstValueFrom(this.getFeatureFlag$(key, defaultValue));
}
triggerServerConfigFetch() {
this._forceFetchConfig.next();
}
private async saveConfig(data: ServerConfigData) {
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) {
return;
}
await this.stateService.setServerConfig(data);
this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion);
}
}

View File

@@ -77,6 +77,12 @@ export class CryptoService implements CryptoServiceAbstraction {
}
}
async isLegacyUser(masterKey?: MasterKey, userId?: string): Promise<boolean> {
return await this.validateUserKey(
(masterKey ?? (await this.getMasterKey(userId))) as unknown as UserKey
);
}
async getUserKeyWithLegacySupport(userId?: string): Promise<UserKey> {
const userKey = await this.getUserKey(userId);
if (userKey) {
@@ -119,7 +125,7 @@ export class CryptoService implements CryptoServiceAbstraction {
throw new Error("No Master Key found.");
}
const newUserKey = await this.cryptoFunctionService.randomBytes(64);
const newUserKey = await this.cryptoFunctionService.aesGenerateKey(512);
return this.buildProtectedSymmetricKey(masterKey, newUserKey);
}
@@ -367,7 +373,7 @@ export class CryptoService implements CryptoServiceAbstraction {
throw new Error("No key provided");
}
const newSymKey = await this.cryptoFunctionService.randomBytes(64);
const newSymKey = await this.cryptoFunctionService.aesGenerateKey(512);
return this.buildProtectedSymmetricKey(key, newSymKey);
}
@@ -458,7 +464,7 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async makeOrgKey<T extends OrgKey | ProviderKey>(): Promise<[EncString, T]> {
const shareKey = await this.cryptoFunctionService.randomBytes(64);
const shareKey = await this.cryptoFunctionService.aesGenerateKey(512);
const publicKey = await this.getPublicKey();
const encShareKey = await this.rsaEncrypt(shareKey, publicKey);
return [encShareKey, new SymmetricCryptoKey(shareKey) as T];
@@ -510,7 +516,8 @@ export class CryptoService implements CryptoServiceAbstraction {
}
async makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]> {
key ||= await this.getUserKey();
// Default to user key
key ||= await this.getUserKeyWithLegacySupport();
const keyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
const publicB64 = Utils.fromBufferToB64(keyPair[0]);
@@ -731,8 +738,8 @@ export class CryptoService implements CryptoServiceAbstraction {
publicKey: string;
privateKey: EncString;
}> {
const randomBytes = await this.cryptoFunctionService.randomBytes(64);
const userKey = new SymmetricCryptoKey(randomBytes) as UserKey;
const rawKey = await this.cryptoFunctionService.aesGenerateKey(512);
const userKey = new SymmetricCryptoKey(rawKey) as UserKey;
const [publicKey, privateKey] = await this.makeKeyPair(userKey);
await this.setUserKey(userKey);
await this.stateService.setEncryptedPrivateKey(privateKey.encryptedString);
@@ -943,23 +950,30 @@ export class CryptoService implements CryptoServiceAbstraction {
async migrateAutoKeyIfNeeded(userId?: string) {
const oldAutoKey = await this.stateService.getCryptoMasterKeyAuto({ userId: userId });
if (oldAutoKey) {
// decrypt
const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldAutoKey)) as MasterKey;
const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({
userId: userId,
});
const userKey = await this.decryptUserKeyWithMasterKey(
masterKey,
new EncString(encryptedUserKey),
userId
);
// migrate
await this.stateService.setUserKeyAutoUnlock(userKey.keyB64, { userId: userId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
// set encrypted user key in case user immediately locks without syncing
await this.setMasterKeyEncryptedUserKey(encryptedUserKey);
if (!oldAutoKey) {
return;
}
// Decrypt
const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldAutoKey)) as MasterKey;
if (await this.isLegacyUser(masterKey, userId)) {
// Legacy users don't have a user key, so no need to migrate.
// Instead, set the master key for additional isLegacyUser checks that will log the user out.
await this.setMasterKey(masterKey, userId);
return;
}
const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({
userId: userId,
});
const userKey = await this.decryptUserKeyWithMasterKey(
masterKey,
new EncString(encryptedUserKey),
userId
);
// Migrate
await this.stateService.setUserKeyAutoUnlock(userKey.keyB64, { userId: userId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
// Set encrypted user key in case user immediately locks without syncing
await this.setMasterKeyEncryptedUserKey(encryptedUserKey);
}
async decryptAndMigrateOldPinKey(

View File

@@ -1,4 +1,4 @@
import { concatMap, Observable, Subject } from "rxjs";
import { concatMap, Observable, ReplaySubject } from "rxjs";
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
import {
@@ -9,7 +9,7 @@ import {
import { StateService } from "../abstractions/state.service";
export class EnvironmentService implements EnvironmentServiceAbstraction {
private readonly urlsSubject = new Subject<void>();
private readonly urlsSubject = new ReplaySubject<void>(1);
urls: Observable<void> = this.urlsSubject.asObservable();
selectedRegion?: Region;
initialized = false;

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,7 +21,10 @@ import {
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
import { migrate } from "../../state-migrations";
import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
import { UsernameGeneratorOptions } from "../../tools/generator/username";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendView } from "../../tools/send/models/view/send.view";
import { CipherData } from "../../vault/models/data/cipher.data";
@@ -32,7 +35,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 +63,7 @@ import {
const keys = {
state: "state",
stateVersion: "stateVersion",
global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
@@ -106,7 +109,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 +135,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) {
@@ -2369,13 +2369,16 @@ export class StateService<
);
}
async getPasswordGenerationOptions(options?: StorageOptions): Promise<any> {
async getPasswordGenerationOptions(options?: StorageOptions): Promise<PasswordGeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.passwordGenerationOptions;
}
async setPasswordGenerationOptions(value: any, options?: StorageOptions): Promise<void> {
async setPasswordGenerationOptions(
value: PasswordGeneratorOptions,
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
@@ -2386,13 +2389,16 @@ export class StateService<
);
}
async getUsernameGenerationOptions(options?: StorageOptions): Promise<any> {
async getUsernameGenerationOptions(options?: StorageOptions): Promise<UsernameGeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.usernameGenerationOptions;
}
async setUsernameGenerationOptions(value: any, options?: StorageOptions): Promise<void> {
async setUsernameGenerationOptions(
value: UsernameGeneratorOptions,
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
@@ -2403,13 +2409,13 @@ export class StateService<
);
}
async getGeneratorOptions(options?: StorageOptions): Promise<any> {
async getGeneratorOptions(options?: StorageOptions): Promise<GeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.generatorOptions;
}
async setGeneratorOptions(value: any, options?: StorageOptions): Promise<void> {
async setGeneratorOptions(value: GeneratorOptions, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
@@ -2724,16 +2730,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 +2834,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) {

View File

@@ -354,6 +354,20 @@ describe("WebCrypto Function Service", () => {
).toBeTruthy();
});
});
describe("aesGenerateKey", () => {
it.each([128, 192, 256, 512])("Should make a key of %s bits long", async (length) => {
const cryptoFunctionService = getWebCryptoFunctionService();
const key = await cryptoFunctionService.aesGenerateKey(length);
expect(key.byteLength * 8).toBe(length);
});
it("should not repeat itself for 512 length special case", async () => {
const cryptoFunctionService = getWebCryptoFunctionService();
const key = await cryptoFunctionService.aesGenerateKey(512);
expect(key.slice(0, 32)).not.toEqual(key.slice(32, 64));
});
});
});
function testPbkdf2(

View File

@@ -347,6 +347,23 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return new Uint8Array(buffer);
}
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
if (bitLength === 512) {
// 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys
const key1 = await this.aesGenerateKey(256);
const key2 = await this.aesGenerateKey(256);
return new Uint8Array([...key1, ...key2]) as CsprngArray;
}
const aesParams = {
name: "AES-CBC",
length: bitLength,
};
const key = await this.subtle.generateKey(aesParams, true, ["encrypt", "decrypt"]);
const rawKey = await this.subtle.exportKey("raw", key);
return new Uint8Array(rawKey) as CsprngArray;
}
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
const rsaParams = {
name: "RSA-OAEP",
@@ -355,10 +372,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
// Have to specify some algorithm
hash: { name: this.toWebCryptoAlgorithm("sha1") },
};
const keyPair = (await this.subtle.generateKey(rsaParams, true, [
"encrypt",
"decrypt",
])) as CryptoKeyPair;
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];

View File

@@ -5,6 +5,7 @@ import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/va
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { ClientType } from "../../enums";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -141,10 +142,18 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
}
private async migrateKeyForNeverLockIfNeeded(): Promise<void> {
// Web can't set vault timeout to never
if (this.platformUtilsService.getClientType() == ClientType.Web) {
return;
}
const accounts = await firstValueFrom(this.stateService.accounts$);
for (const userId in accounts) {
if (userId != null) {
await this.cryptoService.migrateAutoKeyIfNeeded(userId);
// Legacy users should be logged out since we're not on the web vault and can't migrate.
if (await this.cryptoService.isLegacyUser(null, userId)) {
await this.logOut(userId);
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"overrides": [
{
"files": ["*"],
"rules": {
"import/no-restricted-paths": [
"error",
{
"basePath": "libs/common/src/state-migrations",
"zones": [
{
"target": "./",
"from": "../",
// Relative to from, not basePath
"except": ["state-migrations"],
"message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
export { migrate, CURRENT_VERSION } from "./migrate";

View File

@@ -0,0 +1,67 @@
import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { CURRENT_VERSION, currentVersion, migrate } from "./migrate";
import { MigrationBuilder } from "./migration-builder";
jest.mock("./migration-builder", () => {
return {
MigrationBuilder: {
create: jest.fn().mockReturnThis(),
},
};
});
describe("migrate", () => {
it("should not run migrations if state is empty", async () => {
const storage = mock<AbstractStorageService>();
const logService = mock<LogService>();
storage.get.mockReturnValueOnce(null);
await migrate(storage, logService);
expect(MigrationBuilder.create).not.toHaveBeenCalled();
});
it("should set to current version if state is empty", async () => {
const storage = mock<AbstractStorageService>();
const logService = mock<LogService>();
storage.get.mockReturnValueOnce(null);
await migrate(storage, logService);
expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION);
});
});
describe("currentVersion", () => {
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
beforeEach(() => {
storage = mock();
logService = mock();
});
it("should return -1 if no version", async () => {
storage.get.mockReturnValueOnce(null);
expect(await currentVersion(storage, logService)).toEqual(-1);
});
it("should return version", async () => {
storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
it("should return version from global", async () => {
storage.get.calledWith("stateVersion").mockReturnValueOnce(null);
storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
it("should prefer root version to global", async () => {
storage.get.calledWith("stateVersion").mockReturnValue(1 as any);
storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any);
expect(await currentVersion(storage, logService)).toEqual(1);
});
});

View File

@@ -0,0 +1,60 @@
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationBuilder } from "./migration-builder";
import { MigrationHelper } from "./migration-helper";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
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";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 8;
export type MinVersion = typeof MIN_VERSION;
export async function migrate(
storageService: AbstractStorageService,
logService: LogService
): Promise<void> {
const migrationHelper = new MigrationHelper(
await currentVersion(storageService, logService),
storageService,
logService
);
if (migrationHelper.currentVersion < 0) {
// Cannot determine state, assuming empty so we don't repeatedly apply a migration.
await storageService.save("stateVersion", CURRENT_VERSION);
return;
}
MigrationBuilder.create()
.with(MinVersionMigrator)
.with(FixPremiumMigrator, 2, 3)
.with(RemoveEverBeenUnlockedMigrator, 3, 4)
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
.with(MoveBiometricAutoPromptToAccount, 6, 7)
.with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
.migrate(migrationHelper);
}
export async function currentVersion(
storageService: AbstractStorageService,
logService: LogService
) {
let state = await storageService.get<number>("stateVersion");
if (state == null) {
// Pre v8
state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion;
}
if (state == null) {
logService.info("No state version found, assuming empty state.");
return -1;
}
logService.info(`State version: ${state}`);
return state;
}

View File

@@ -0,0 +1,117 @@
import { mock } from "jest-mock-extended";
import { MigrationBuilder } from "./migration-builder";
import { MigrationHelper } from "./migration-helper";
import { Migrator } from "./migrator";
describe("MigrationBuilder", () => {
class TestMigrator extends Migrator<0, 1> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
return;
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
return;
}
}
let sut: MigrationBuilder<number>;
beforeEach(() => {
sut = MigrationBuilder.create();
});
class TestBadMigrator extends Migrator<1, 0> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
}
}
it("should throw if instantiated incorrectly", () => {
expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow();
expect(() =>
MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0)
).toThrow();
});
it("should be able to create a new MigrationBuilder", () => {
expect(sut).toBeInstanceOf(MigrationBuilder);
});
it("should be able to add a migrator", () => {
const newBuilder = sut.with(TestMigrator, 0, 1);
const migrations = newBuilder["migrations"];
expect(migrations.length).toBe(1);
expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" });
});
it("should be able to add a rollback", () => {
const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
const migrations = newBuilder["migrations"];
expect(migrations.length).toBe(2);
expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
});
describe("migrate", () => {
let migrator: TestMigrator;
let rollback_migrator: TestMigrator;
beforeEach(() => {
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
migrator = (sut as any).migrations[0].migrator;
rollback_migrator = (sut as any).migrations[1].migrator;
});
it("should migrate", async () => {
const helper = new MigrationHelper(0, mock(), mock());
const spy = jest.spyOn(migrator, "migrate");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper);
});
it("should rollback", async () => {
const helper = new MigrationHelper(1, mock(), mock());
const spy = jest.spyOn(rollback_migrator, "rollback");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper);
});
it("should update version on migrate", async () => {
const helper = new MigrationHelper(0, mock(), mock());
const spy = jest.spyOn(migrator, "updateVersion");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper, "up");
});
it("should update version on rollback", async () => {
const helper = new MigrationHelper(1, mock(), mock());
const spy = jest.spyOn(rollback_migrator, "updateVersion");
await sut.migrate(helper);
expect(spy).toBeCalledWith(helper, "down");
});
it("should not run the migrator if the current version does not match the from version", async () => {
const helper = new MigrationHelper(3, mock(), mock());
const migrate = jest.spyOn(migrator, "migrate");
const rollback = jest.spyOn(rollback_migrator, "rollback");
await sut.migrate(helper);
expect(migrate).not.toBeCalled();
expect(rollback).not.toBeCalled();
});
it("should not update version if the current version does not match the from version", async () => {
const helper = new MigrationHelper(3, mock(), mock());
const migrate = jest.spyOn(migrator, "updateVersion");
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
await sut.migrate(helper);
expect(migrate).not.toBeCalled();
expect(rollback).not.toBeCalled();
});
});
});

View File

@@ -0,0 +1,106 @@
import { MigrationHelper } from "./migration-helper";
import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
export class MigrationBuilder<TCurrent extends number = 0> {
/** Create a new MigrationBuilder with an empty buffer of migrations to perform.
*
* Add migrations to the buffer with {@link with} and {@link rollback}.
* @returns A new MigrationBuilder.
*/
static create(): MigrationBuilder<0> {
return new MigrationBuilder([]);
}
private constructor(
private migrations: readonly { migrator: Migrator<number, number>; direction: Direction }[]
) {}
/** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
* at state version equal to the from version of the migrator. Return as MigrationBuilder<TTo> where TTo is the to
* version of the migrator, so that the next migrator can be chained.
*
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
* required to instantiate version numbers unless a default constructor is defined.
* @returns A new MigrationBuilder with the to version of the migrator as the current version.
*/
with<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator> & TCurrent,
TTo extends VersionTo<TMigrator>
>(
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
): MigrationBuilder<TTo> {
return this.addMigrator(migrate, "up");
}
/** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
* MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
* MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder<TFrom> where TFrom
* is the from version of the migrator, so that the next migrator can be chained.
*
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
* required to instantiate version numbers unless a default constructor is defined.
* @returns A new MigrationBuilder with the from version of the migrator as the current version.
*/
rollback<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator>,
TTo extends VersionTo<TMigrator> & TCurrent
>(
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
): MigrationBuilder<TFrom> {
if (migrate.length === 3) {
migrate = [migrate[0], migrate[2], migrate[1]];
}
return this.addMigrator(migrate, "down");
}
/** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
migrate(helper: MigrationHelper): Promise<void> {
return this.migrations.reduce(
(promise, migrator) =>
promise.then(async () => {
await this.runMigrator(migrator.migrator, helper, migrator.direction);
}),
Promise.resolve()
);
}
private addMigrator<
TMigrator extends Migrator<number, number>,
TFrom extends VersionFrom<TMigrator> & TCurrent,
TTo extends VersionTo<TMigrator>
>(
migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
direction: Direction = "up"
) {
const newMigration =
migrate.length === 1
? { migrator: new migrate[0](), direction }
: { migrator: new migrate[0](migrate[1], migrate[2]), direction };
return new MigrationBuilder<TTo>([...this.migrations, newMigration]);
}
private async runMigrator(
migrator: Migrator<number, number>,
helper: MigrationHelper,
direction: Direction
): Promise<void> {
const shouldMigrate = await migrator.shouldMigrate(helper, direction);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`
);
if (shouldMigrate) {
const method = direction === "up" ? migrator.migrate : migrator.rollback;
await method(helper);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`
);
await migrator.updateVersion(helper, direction);
helper.info(
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`
);
}
}
}

View File

@@ -0,0 +1,84 @@
import { MockProxy, mock } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationHelper } from "./migration-helper";
const exampleJSON = {
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
otherStuff: "otherStuff1",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
otherStuff: "otherStuff2",
},
};
describe("RemoveLegacyEtmKeyMigrator", () => {
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
let sut: MigrationHelper;
beforeEach(() => {
logService = mock();
storage = mock();
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
sut = new MigrationHelper(0, storage, logService);
});
describe("get", () => {
it("should delegate to storage.get", async () => {
await sut.get("key");
expect(storage.get).toHaveBeenCalledWith("key");
});
});
describe("set", () => {
it("should delegate to storage.save", async () => {
await sut.set("key", "value");
expect(storage.save).toHaveBeenCalledWith("key", "value");
});
});
describe("getAccounts", () => {
it("should return all accounts", async () => {
const accounts = await sut.getAccounts();
expect(accounts).toEqual([
{ userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } },
{ userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } },
]);
});
it("should handle missing authenticatedAccounts", async () => {
storage.get.mockImplementation((key) =>
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key]
);
const accounts = await sut.getAccounts();
expect(accounts).toEqual([]);
});
});
});
/** Helper to create well-mocked migration helpers in migration tests */
export function mockMigrationHelper(storageJson: any): MockProxy<MigrationHelper> {
const logService: MockProxy<LogService> = mock();
const storage: MockProxy<AbstractStorageService> = mock();
storage.get.mockImplementation((key) => (storageJson as any)[key]);
storage.save.mockImplementation(async (key, value) => {
(storageJson as any)[key] = value;
});
const helper = new MigrationHelper(0, storage, logService);
const mockHelper = mock<MigrationHelper>();
mockHelper.get.mockImplementation((key) => helper.get(key));
mockHelper.set.mockImplementation((key, value) => helper.set(key, value));
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
return mockHelper;
}

View File

@@ -0,0 +1,37 @@
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
export class MigrationHelper {
constructor(
public currentVersion: number,
private storageService: AbstractStorageService,
public logService: LogService
) {}
get<T>(key: string): Promise<T> {
return this.storageService.get<T>(key);
}
set<T>(key: string, value: T): Promise<void> {
this.logService.info(`Setting ${key}`);
return this.storageService.save(key, value);
}
info(message: string): void {
this.logService.info(message);
}
async getAccounts<ExpectedAccountType>(): Promise<
{ userId: string; account: ExpectedAccountType }[]
> {
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
return Promise.all(
userIds.map(async (userId) => ({
userId,
account: await this.get<ExpectedAccountType>(userId),
}))
);
}
}

View File

@@ -0,0 +1,111 @@
import { MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import
import { TokenService } from "../../auth/services/token.service";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { FixPremiumMigrator } from "./3-fix-premium";
function migrateExampleJSON() {
return {
global: {
stateVersion: 2,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
otherStuff: "otherStuff2",
hasPremiumPersonally: null as boolean,
},
tokens: {
otherStuff: "otherStuff3",
accessToken: "accessToken",
},
otherStuff: "otherStuff4",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
otherStuff: "otherStuff5",
hasPremiumPersonally: true,
},
tokens: {
otherStuff: "otherStuff6",
accessToken: "accessToken",
},
otherStuff: "otherStuff7",
},
otherStuff: "otherStuff8",
};
}
jest.mock("../../auth/services/token.service", () => ({
TokenService: {
decodeToken: jest.fn(),
},
}));
describe("FixPremiumMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: FixPremiumMigrator;
const decodeTokenSpy = TokenService.decodeToken as jest.Mock;
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new FixPremiumMigrator(2, 3);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("migrate", () => {
it("should migrate hasPremiumPersonally", async () => {
decodeTokenSpy.mockResolvedValueOnce({ premium: true });
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
otherStuff: "otherStuff2",
hasPremiumPersonally: true,
},
tokens: {
otherStuff: "otherStuff3",
accessToken: "accessToken",
},
otherStuff: "otherStuff4",
});
});
it("should not migrate if decode throws", async () => {
decodeTokenSpy.mockRejectedValueOnce(new Error("test"));
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalled();
});
it("should not migrate if decode returns null", async () => {
decodeTokenSpy.mockResolvedValueOnce(null);
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalled();
});
});
describe("updateVersion", () => {
it("should update version", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 3,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,48 @@
// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest
import { TokenService } from "../../auth/services/token.service";
import { MigrationHelper } from "../migration-helper";
import { Migrator, IRREVERSIBLE, Direction } from "../migrator";
type ExpectedAccountType = {
profile?: { hasPremiumPersonally?: boolean };
tokens?: { accessToken?: string };
};
export class FixPremiumMigrator extends Migrator<2, 3> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function fixPremium(userId: string, account: ExpectedAccountType) {
if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) {
let decodedToken: { premium: boolean };
try {
decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
} catch {
return;
}
if (decodedToken?.premium == null) {
return;
}
account.profile.hasPremiumPersonally = decodedToken?.premium;
return helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account)));
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: Record<string, unknown> = (await helper.get("global")) || {};
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,75 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked";
function migrateExampleJSON() {
return {
global: {
stateVersion: 3,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
otherStuff: "otherStuff2",
everBeenUnlocked: true,
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
otherStuff: "otherStuff4",
everBeenUnlocked: false,
},
otherStuff: "otherStuff5",
},
otherStuff: "otherStuff6",
};
}
describe("RemoveEverBeenUnlockedMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveEverBeenUnlockedMigrator;
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new RemoveEverBeenUnlockedMigrator(3, 4);
});
describe("migrate", () => {
it("should remove everBeenUnlocked from profile", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 4,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } };
export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) {
if (account?.profile?.everBeenUnlocked != null) {
delete account.profile.everBeenUnlocked;
return helper.set(userId, account);
}
}
Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account)));
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,141 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys";
function migrateExampleJSON() {
return {
global: {
stateVersion: 4,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
};
}
function rollbackExampleJSON() {
return {
global: {
stateVersion: 5,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
};
}
describe("AddKeyTypeToOrgKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AddKeyTypeToOrgKeysMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
});
it("should add organization type to organization keys", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
organizationKeys: {
encrypted: {
orgOneId: {
type: "organization",
key: "orgOneEncKey",
},
orgTwoId: {
type: "organization",
key: "orgTwoEncKey",
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should update version", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 5,
otherStuff: "otherStuff1",
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackExampleJSON());
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
});
it("should remove type from orgainzation keys", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
organizationKeys: {
encrypted: {
orgOneId: "orgOneEncKey",
orgTwoId: "orgTwoEncKey",
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should update version down", async () => {
await sut.updateVersion(helper, "down");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 4,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, Migrator } from "../migrator";
type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record<string, string> } } };
type NewAccountType = {
keys?: {
organizationKeys?: { encrypted: Record<string, { type: "organization"; key: string }> };
};
};
export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts();
async function updateOrgKey(userId: string, account: ExpectedAccountType) {
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys == null) {
return;
}
const newOrgKeys: Record<string, { type: "organization"; key: string }> = {};
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
newOrgKeys[orgId] = {
type: "organization",
key: encKey,
};
});
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
await helper.set(userId, account);
}
Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts();
async function updateOrgKey(userId: string, account: NewAccountType) {
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
if (encryptedOrgKeys == null) {
return;
}
const newOrgKeys: Record<string, string> = {};
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
newOrgKeys[orgId] = encKey.key;
});
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
await helper.set(userId, account);
}
Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account)));
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,80 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key";
function exampleJSON() {
return {
global: {
stateVersion: 5,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
keys: {
legacyEtmKey: "legacyEtmKey",
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
keys: {
legacyEtmKey: "legacyEtmKey",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("RemoveLegacyEtmKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RemoveLegacyEtmKeyMigrator;
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON());
sut = new RemoveLegacyEtmKeyMigrator(5, 6);
});
describe("migrate", () => {
it("should remove legacyEtmKey from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
keys: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
});
describe("rollback", () => {
it("should throw", async () => {
await expect(sut.rollback(helper)).rejects.toThrow();
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 6,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { keys?: { legacyEtmKey?: string } };
export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function updateAccount(userId: string, account: ExpectedAccountType) {
if (account?.keys?.legacyEtmKey) {
delete account.keys.legacyEtmKey;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,102 @@
import { MockProxy, any, matches } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account";
function exampleJSON() {
return {
global: {
stateVersion: 6,
noAutoPromptBiometrics: true,
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("RemoveLegacyEtmKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBiometricAutoPromptToAccount;
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON());
sut = new MoveBiometricAutoPromptToAccount(6, 7);
});
describe("migrate", () => {
it("should remove noAutoPromptBiometrics from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
stateVersion: 6,
});
});
it("should set disableAutoBiometricsPrompt to true on all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
settings: {
disableAutoBiometricsPrompt: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
settings: {
disableAutoBiometricsPrompt: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => {
const json = exampleJSON();
json.global.noAutoPromptBiometrics = false;
helper = mockMigrationHelper(json);
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalledWith(
matches((s) => s != "global"),
any()
);
});
});
describe("rollback", () => {
it("should throw", async () => {
await expect(sut.rollback(helper)).rejects.toThrow();
});
});
describe("updateVersion", () => {
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith(
"global",
Object.assign({}, exampleJSON().global, {
stateVersion: 7,
})
);
});
});
});

View File

@@ -0,0 +1,45 @@
import { MigrationHelper } from "../migration-helper";
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } };
export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> {
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global");
const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false;
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function updateAccount(userId: string, account: ExpectedAccountType) {
if (account == null) {
return;
}
if (noAutoPromptBiometrics) {
account.settings = Object.assign(account?.settings ?? {}, {
disableAutoBiometricsPrompt: true,
});
await helper.set(userId, account);
}
}
delete global.noAutoPromptBiometrics;
await Promise.all([
...accounts.map(({ userId, account }) => updateAccount(userId, account)),
helper.set("global", global),
]);
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
// it is nested inside a global object.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}

View File

@@ -0,0 +1,90 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MoveStateVersionMigrator } from "./8-move-state-version";
function migrateExampleJSON() {
return {
global: {
stateVersion: 6,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackExampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
stateVersion: 7,
otherStuff: "otherStuff2",
};
}
describe("moveStateVersion", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveStateVersionMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateExampleJSON());
sut = new MoveStateVersionMigrator(7, 8);
});
it("should move state version to root", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("stateVersion", 6);
});
it("should remove state version from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should throw if state version not found", async () => {
helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any);
await expect(sut.migrate(helper)).rejects.toThrow(
"Migration failed, state version not found"
);
});
it("should update version up", async () => {
await sut.updateVersion(helper, "up");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("stateVersion", 8);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackExampleJSON());
sut = new MoveStateVersionMigrator(7, 8);
});
it("should move state version to global", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 7,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined);
});
it("should update version down", async () => {
await sut.updateVersion(helper, "down");
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
stateVersion: 7,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,37 @@
import { JsonObject } from "type-fest";
import { MigrationHelper } from "../migration-helper";
import { Direction, Migrator } from "../migrator";
export class MoveStateVersionMigrator extends Migrator<7, 8> {
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<{ stateVersion: number }>("global");
if (global.stateVersion) {
await helper.set("stateVersion", global.stateVersion);
delete global.stateVersion;
await helper.set("global", global);
} else {
throw new Error("Migration failed, state version not found");
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const version = await helper.get<number>("stateVersion");
const global = await helper.get<JsonObject>("global");
await helper.set("global", { ...global, stateVersion: version });
await helper.set("stateVersion", undefined);
}
// Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves
// it from a `global` object to root.This makes for unique rollback versioning.
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
if (direction === "up") {
await helper.set("stateVersion", endVersion);
} else {
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
await helper.set("global", { ...global, stateVersion: endVersion });
}
}
}

View File

@@ -0,0 +1,29 @@
import { MockProxy } from "jest-mock-extended";
import { MIN_VERSION } from "../migrate";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { MinVersionMigrator } from "./min-version";
describe("MinVersionMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MinVersionMigrator;
beforeEach(() => {
helper = mockMigrationHelper(null);
sut = new MinVersionMigrator();
});
describe("shouldMigrate", () => {
it("should return true if current version is less than min version", async () => {
helper.currentVersion = MIN_VERSION - 1;
expect(await sut.shouldMigrate(helper)).toBe(true);
});
it("should return false if current version is greater than min version", async () => {
helper.currentVersion = MIN_VERSION + 1;
expect(await sut.shouldMigrate(helper)).toBe(false);
});
});
});

View File

@@ -0,0 +1,26 @@
import { MinVersion, MIN_VERSION } from "../migrate";
import { MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
export function minVersionError(current: number) {
return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`;
}
export class MinVersionMigrator extends Migrator<0, MinVersion> {
constructor() {
super(0, MIN_VERSION);
}
// Overrides the default implementation to catch any version that may be passed in.
override shouldMigrate(helper: MigrationHelper): Promise<boolean> {
return Promise.resolve(helper.currentVersion < MIN_VERSION);
}
async migrate(helper: MigrationHelper): Promise<void> {
if (helper.currentVersion < MIN_VERSION) {
throw new Error(minVersionError(helper.currentVersion));
}
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@@ -0,0 +1,75 @@
import { mock, MockProxy } from "jest-mock-extended";
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
import { LogService } from "../platform/abstractions/log.service";
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
import { AbstractStorageService } from "../platform/abstractions/storage.service";
import { MigrationHelper } from "./migration-helper";
import { Migrator } from "./migrator";
describe("migrator default methods", () => {
class TestMigrator extends Migrator<0, 1> {
async migrate(helper: MigrationHelper): Promise<void> {
await helper.set("test", "test");
}
async rollback(helper: MigrationHelper): Promise<void> {
await helper.set("test", "rollback");
}
}
let storage: MockProxy<AbstractStorageService>;
let logService: MockProxy<LogService>;
let helper: MigrationHelper;
let sut: TestMigrator;
beforeEach(() => {
storage = mock();
logService = mock();
helper = new MigrationHelper(0, storage, logService);
sut = new TestMigrator(0, 1);
});
describe("shouldMigrate", () => {
describe("up", () => {
it("should return true if the current version equals the from version", async () => {
expect(await sut.shouldMigrate(helper, "up")).toBe(true);
});
it("should return false if the current version does not equal the from version", async () => {
helper.currentVersion = 1;
expect(await sut.shouldMigrate(helper, "up")).toBe(false);
});
});
describe("down", () => {
it("should return true if the current version equals the to version", async () => {
helper.currentVersion = 1;
expect(await sut.shouldMigrate(helper, "down")).toBe(true);
});
it("should return false if the current version does not equal the to version", async () => {
expect(await sut.shouldMigrate(helper, "down")).toBe(false);
});
});
});
describe("updateVersion", () => {
describe("up", () => {
it("should update the version", async () => {
await sut.updateVersion(helper, "up");
expect(storage.save).toBeCalledWith("stateVersion", 1);
expect(helper.currentVersion).toBe(1);
});
});
describe("down", () => {
it("should update the version", async () => {
helper.currentVersion = 1;
await sut.updateVersion(helper, "down");
expect(storage.save).toBeCalledWith("stateVersion", 0);
expect(helper.currentVersion).toBe(0);
});
});
});
});

View File

@@ -0,0 +1,40 @@
import { NonNegativeInteger } from "type-fest";
import { MigrationHelper } from "./migration-helper";
export const IRREVERSIBLE = new Error("Irreversible migration");
export type VersionFrom<T> = T extends Migrator<infer TFrom, number>
? TFrom extends NonNegativeInteger<TFrom>
? TFrom
: never
: never;
export type VersionTo<T> = T extends Migrator<number, infer TTo>
? TTo extends NonNegativeInteger<TTo>
? TTo
: never
: never;
export type Direction = "up" | "down";
export abstract class Migrator<TFrom extends number, TTo extends number> {
constructor(public fromVersion: TFrom, public toVersion: TTo) {
if (fromVersion == null || toVersion == null) {
throw new Error("Invalid migration");
}
if (fromVersion > toVersion) {
throw new Error("Invalid migration");
}
}
shouldMigrate(helper: MigrationHelper, direction: Direction): Promise<boolean> {
const startVersion = direction === "up" ? this.fromVersion : this.toVersion;
return Promise.resolve(helper.currentVersion === startVersion);
}
abstract migrate(helper: MigrationHelper): Promise<void>;
abstract rollback(helper: MigrationHelper): Promise<void>;
async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
helper.currentVersion = endVersion;
await helper.set("stateVersion", endVersion);
}
}

View File

@@ -0,0 +1,3 @@
export type GeneratorOptions = {
type?: "password" | "username";
};

View File

@@ -1,3 +1,4 @@
export { PasswordGeneratorOptions } from "./password-generator-options";
export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
export { PasswordGenerationService } from "./password-generation.service";
export { GeneratedPasswordHistory } from "./generated-password-history";

View File

@@ -6,10 +6,10 @@ import { ForwarderOptions } from "./forwarder-options";
export class AnonAddyForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid AnonAddy API token.";
throw "Invalid addy.io API token.";
}
if (options.anonaddy?.domain == null || options.anonaddy.domain === "") {
throw "Invalid AnonAddy domain.";
throw "Invalid addy.io domain.";
}
const requestInit: RequestInit = {
redirect: "manual",
@@ -21,7 +21,7 @@ export class AnonAddyForwarder implements Forwarder {
"X-Requested-With": "XMLHttpRequest",
}),
};
const url = "https://app.anonaddy.com/api/v1/aliases";
const url = "https://app.addy.io/api/v1/aliases";
requestInit.body = JSON.stringify({
domain: options.anonaddy.domain,
description:
@@ -35,8 +35,8 @@ export class AnonAddyForwarder implements Forwarder {
return json?.data?.email;
}
if (response.status === 401) {
throw "Invalid AnonAddy API token.";
throw "Invalid addy.io API token.";
}
throw "Unknown AnonAddy error occurred.";
throw "Unknown addy.io error occurred.";
}
}

View File

@@ -1,2 +1,3 @@
export { UsernameGeneratorOptions } from "./username-generation-options";
export { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
export { UsernameGenerationService } from "./username-generation.service";

View File

@@ -0,0 +1,19 @@
export type UsernameGeneratorOptions = {
type?: "word" | "subaddress" | "catchall" | "forwarded";
wordCapitalize?: boolean;
wordIncludeNumber?: boolean;
subaddressType?: "random" | "website-name";
subaddressEmail?: string;
catchallType?: "random" | "website-name";
catchallDomain?: string;
website?: string;
forwardedService?: string;
forwardedAnonAddyApiToken?: string;
forwardedAnonAddyDomain?: string;
forwardedDuckDuckGoToken?: string;
forwardedFirefoxApiToken?: string;
forwardedFastmailApiToken?: string;
forwardedForwardEmailApiToken?: string;
forwardedForwardEmailDomain?: string;
forwardedSimpleLoginApiKey?: string;
};

View File

@@ -1,9 +1,11 @@
import { UsernameGeneratorOptions } from "./username-generation-options";
export abstract class UsernameGenerationServiceAbstraction {
generateUsername: (options: any) => Promise<string>;
generateWord: (options: any) => Promise<string>;
generateSubaddress: (options: any) => Promise<string>;
generateCatchall: (options: any) => Promise<string>;
generateForwarded: (options: any) => Promise<string>;
getOptions: () => Promise<any>;
saveOptions: (options: any) => Promise<void>;
generateUsername: (options: UsernameGeneratorOptions) => Promise<string>;
generateWord: (options: UsernameGeneratorOptions) => Promise<string>;
generateSubaddress: (options: UsernameGeneratorOptions) => Promise<string>;
generateCatchall: (options: UsernameGeneratorOptions) => Promise<string>;
generateForwarded: (options: UsernameGeneratorOptions) => Promise<string>;
getOptions: () => Promise<UsernameGeneratorOptions>;
saveOptions: (options: UsernameGeneratorOptions) => Promise<void>;
}

View File

@@ -13,9 +13,10 @@ import {
ForwarderOptions,
SimpleLoginForwarder,
} from "./email-forwarders";
import { UsernameGeneratorOptions } from "./username-generation-options";
import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction";
const DefaultOptions = {
const DefaultOptions: UsernameGeneratorOptions = {
type: "word",
wordCapitalize: true,
wordIncludeNumber: true,
@@ -33,7 +34,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
private apiService: ApiService
) {}
generateUsername(options: any): Promise<string> {
generateUsername(options: UsernameGeneratorOptions): Promise<string> {
if (options.type === "catchall") {
return this.generateCatchall(options);
} else if (options.type === "subaddress") {
@@ -45,7 +46,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
}
}
async generateWord(options: any): Promise<string> {
async generateWord(options: UsernameGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.wordCapitalize == null) {
@@ -67,7 +68,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
return word;
}
async generateSubaddress(options: any): Promise<string> {
async generateSubaddress(options: UsernameGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
const subaddressEmail = o.subaddressEmail;
@@ -94,7 +95,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
}
async generateCatchall(options: any): Promise<string> {
async generateCatchall(options: UsernameGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.catchallDomain == null || o.catchallDomain === "") {
@@ -113,7 +114,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
return startString + "@" + o.catchallDomain;
}
async generateForwarded(options: any): Promise<string> {
async generateForwarded(options: UsernameGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
if (o.forwardedService == null) {
@@ -152,7 +153,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
return forwarder.generate(this.apiService, forwarderOptions);
}
async getOptions(): Promise<any> {
async getOptions(): Promise<UsernameGeneratorOptions> {
let options = await this.stateService.getUsernameGenerationOptions();
if (options == null) {
options = Object.assign({}, DefaultOptions);
@@ -163,7 +164,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr
return options;
}
async saveOptions(options: any) {
async saveOptions(options: UsernameGeneratorOptions) {
await this.stateService.setUsernameGenerationOptions(options);
}

View File

@@ -70,7 +70,7 @@ export class SendService implements InternalSendServiceAbstraction {
send.hideEmail = model.hideEmail;
send.maxAccessCount = model.maxAccessCount;
if (model.key == null) {
model.key = await this.cryptoFunctionService.randomBytes(16);
model.key = await this.cryptoFunctionService.aesGenerateKey(128);
model.cryptoKey = await this.cryptoService.makeSendKey(model.key);
}
if (password != null) {

View File

@@ -134,7 +134,7 @@ describe("Cipher Service", () => {
});
describe("createWithServer()", () => {
it("should call apiService.postCipherAdmin when orgAdmin param is true", async () => {
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
const spy = jest
.spyOn(apiService, "postCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(cipherObj));
@@ -144,6 +144,17 @@ describe("Cipher Service", () => {
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
cipherObj.organizationId = null;
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(cipherObj));
cipherService.createWithServer(cipherObj, true);
const expectedObj = new CipherRequest(cipherObj);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
cipherObj.collectionIds = ["123"];

View File

@@ -329,11 +329,6 @@ export class CipherService implements CipherServiceAbstraction {
return await this.getDecryptedCipherCache();
}
const hasKey = await this.cryptoService.hasUserKey();
if (!hasKey) {
throw new Error("No user key found.");
}
const ciphers = await this.getAll();
const orgKeys = await this.cryptoService.getOrgKeys();
const userKey = await this.cryptoService.getUserKeyWithLegacySupport();
@@ -403,14 +398,21 @@ export class CipherService implements CipherServiceAbstraction {
defaultMatch ??= await this.stateService.getDefaultUriMatch();
return ciphers.filter((cipher) => {
if (cipher.deletedDate != null) {
const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null;
if (cipher.deletedDate !== null) {
return false;
}
if (includeOtherTypes != null && includeOtherTypes.indexOf(cipher.type) > -1) {
if (
Array.isArray(includeOtherTypes) &&
includeOtherTypes.includes(cipher.type) &&
!cipherIsLogin
) {
return true;
}
if (cipher.type === CipherType.Login && cipher.login !== null) {
if (cipherIsLogin) {
return cipher.login.matchesUri(url, equivalentDomains, defaultMatch);
}
@@ -525,7 +527,7 @@ export class CipherService implements CipherServiceAbstraction {
async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<any> {
let response: CipherResponse;
if (orgAdmin) {
if (orgAdmin && cipher.organizationId != null) {
const request = new CipherCreateRequest(cipher);
response = await this.apiService.postCipherAdmin(request);
} else if (cipher.collectionIds != null) {