1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 09:13:33 +00:00

[PM-5404, PM-3518] Migrate user decryption options to new service (#7344)

* create new user decryption options service

* rename new service to user decryption options

* add hasMasterPassword to user decryption options service

* migrate device trust service to new user decryption options service

* add migration for user-decryption-options

* migrate sync service and calls to trust-device-service

* rename abstraction file

* migrate two factor component

* migrate two factor spec

* migrate sso component

* migrate set-password component

* migrate base login decryption component

* migrate organization options component

* fix component imports

* add missing imports
- remove state service calls
- add update user decryption options method

* remove acct decryption options from account

* lint

* fix tests and linting

* fix browser

* fix desktop

* add user decryption options service to cli

* remove default value from migration

* bump migration number

* fix merge conflict

* fix vault timeout settings

* fix cli

* more fixes

* add user decryption options service to deps of vault timeout settings service

* update login strategy service with user decryption options

* remove early return from sync bandaid for user decryption options

* move user decryption options service to lib/auth

* move user decryption options to libs/auth

* fix reference

* fix browser

* check user decryption options after 2fa check

* update migration and revert tsconfig changes

* add more documentation

* clear user decryption options on logout

* fix tests by creating helper for user decryption options

* fix tests

* pr feedback

* fix factory

* update migration

* add tests

* update missed migration num in test
This commit is contained in:
Jake Fink
2024-03-20 20:33:57 -04:00
committed by GitHub
parent e2fe1e1567
commit 2111b37c32
68 changed files with 1158 additions and 360 deletions

View File

@@ -1,8 +1,11 @@
import { Observable } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
export abstract class DeviceTrustCryptoServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
/**
* @description Retrieves the users choice to trust the device which can only happen after decryption
* Note: this value should only be used once and then reset
@@ -20,6 +23,4 @@ export abstract class DeviceTrustCryptoServiceAbstraction {
deviceKey?: DeviceKey,
) => Promise<UserKey | null>;
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
supportsDeviceTrust: () => Promise<boolean>;
}

View File

@@ -8,7 +8,7 @@ export class AuthResult {
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
/**
* @deprecated
* Replace with using AccountDecryptionOptions to determine if the user does
* Replace with using UserDecryptionOptions to determine if the user does
* not have a master password and is not using Key Connector.
* */
resetMasterPassword = false;

View File

@@ -1,3 +0,0 @@
export class KeyConnectorUserDecryptionOption {
constructor(public keyConnectorUrl: string) {}
}

View File

@@ -1,7 +0,0 @@
export class TrustedDeviceUserDecryptionOption {
constructor(
public hasAdminApproval: boolean,
public hasLoginApprovingDevice: boolean,
public hasManageResetPasswordPermission: boolean,
) {}
}

View File

@@ -1,4 +1,6 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
@@ -21,6 +23,8 @@ import {
} from "../models/request/update-devices-trust.request";
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -31,7 +35,12 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
private devicesApiService: DevicesApiServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
) {}
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => options?.trustedDeviceOption != null ?? false),
);
}
/**
* @description Retrieves the users choice to trust the device which can only happen after decryption
@@ -203,9 +212,4 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
return null;
}
}
async supportsDeviceTrust(): Promise<boolean> {
const decryptionOptions = await this.stateService.getAccountDecryptionOptions();
return decryptionOptions?.trustedDeviceOption != null;
}
}

View File

@@ -1,6 +1,9 @@
import { matches, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
import { DeviceType } from "../../enums";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
@@ -34,10 +37,16 @@ describe("deviceTrustCryptoService", () => {
const devicesApiService = mock<DevicesApiServiceAbstraction>();
const i18nService = mock<I18nService>();
const platformUtilsService = mock<PlatformUtilsService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
beforeEach(() => {
jest.clearAllMocks();
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
deviceTrustCryptoService = new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
@@ -48,6 +57,7 @@ describe("deviceTrustCryptoService", () => {
devicesApiService,
i18nService,
platformUtilsService,
userDecryptionOptionsService,
);
});

View File

@@ -1,3 +1,7 @@
import { firstValueFrom } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { PinCryptoServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin-crypto.service.abstraction";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
@@ -33,6 +37,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
private cryptoService: CryptoService,
private i18nService: I18nService,
private userVerificationApiService: UserVerificationApiServiceAbstraction,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private pinCryptoService: PinCryptoServiceAbstraction,
private logService: LogService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction,
@@ -135,7 +140,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
case VerificationType.MasterPassword:
return this.verifyUserByMasterPassword(verification);
case VerificationType.PIN:
return this.verifyUserByPIN(verification);
break;
case VerificationType.Biometrics:
return this.verifyUserByBiometrics();
@@ -210,16 +214,19 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
* Note: This only checks the server, not the local state
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
*/
async hasMasterPassword(userId?: string): Promise<boolean> {
const decryptionOptions = await this.stateService.getAccountDecryptionOptions({ userId });
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
}
}
// TODO: PM-3518 - Left for backwards compatibility, remove after 2023.12.0
return !(await this.stateService.getUsesKeyConnector({ userId }));
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
}
async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean> {

View File

@@ -18,7 +18,7 @@ import { CipherView } from "../../vault/models/view/cipher.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { KdfType } from "../enums";
import { ServerConfigData } from "../models/data/server-config.data";
import { Account, AccountDecryptionOptions } from "../models/domain/account";
import { Account } from "../models/domain/account";
import { EncString } from "../models/domain/enc-string";
import { StorageOptions } from "../models/domain/storage-options";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
@@ -180,13 +180,6 @@ export abstract class StateService<T extends Account = Account> {
) => Promise<void>;
getShouldTrustDevice: (options?: StorageOptions) => Promise<boolean | null>;
setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise<void>;
getAccountDecryptionOptions: (
options?: StorageOptions,
) => Promise<AccountDecryptionOptions | null>;
setAccountDecryptionOptions: (
value: AccountDecryptionOptions,
options?: StorageOptions,
) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;

View File

@@ -2,9 +2,6 @@ import { Jsonify } from "type-fest";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-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 { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { GeneratorOptions } from "../../../tools/generator/generator-options";
import {
@@ -235,103 +232,12 @@ export class AccountTokens {
}
}
export class AccountDecryptionOptions {
hasMasterPassword: boolean;
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
keyConnectorOption?: KeyConnectorUserDecryptionOption;
constructor(init?: Partial<AccountDecryptionOptions>) {
if (init) {
Object.assign(this, init);
}
}
// TODO: these nice getters don't work because the Account object is not properly being deserialized out of
// JSON (the Account static fromJSON method is not running) so these getters don't exist on the
// account decryptions options object when pulled out of state. This is a bug that needs to be fixed later on
// get hasTrustedDeviceOption(): boolean {
// return this.trustedDeviceOption !== null && this.trustedDeviceOption !== undefined;
// }
// get hasKeyConnectorOption(): boolean {
// return this.keyConnectorOption !== null && this.keyConnectorOption !== undefined;
// }
static fromResponse(response: IdentityTokenResponse): AccountDecryptionOptions {
if (response == null) {
return null;
}
const accountDecryptionOptions = new AccountDecryptionOptions();
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,
);
}
}
return accountDecryptionOptions;
}
static fromJSON(obj: Jsonify<AccountDecryptionOptions>): AccountDecryptionOptions {
if (obj == null) {
return null;
}
const accountDecryptionOptions = Object.assign(new AccountDecryptionOptions(), obj);
if (obj.trustedDeviceOption) {
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
obj.trustedDeviceOption.hasAdminApproval,
obj.trustedDeviceOption.hasLoginApprovingDevice,
obj.trustedDeviceOption.hasManageResetPasswordPermission,
);
}
if (obj.keyConnectorOption) {
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
obj.keyConnectorOption.keyConnectorUrl,
);
}
return accountDecryptionOptions;
}
}
export class Account {
data?: AccountData = new AccountData();
keys?: AccountKeys = new AccountKeys();
profile?: AccountProfile = new AccountProfile();
settings?: AccountSettings = new AccountSettings();
tokens?: AccountTokens = new AccountTokens();
decryptionOptions?: AccountDecryptionOptions = new AccountDecryptionOptions();
adminAuthRequest?: Jsonify<AdminAuthRequestStorable> = null;
constructor(init: Partial<Account>) {
@@ -356,10 +262,6 @@ export class Account {
...new AccountTokens(),
...init?.tokens,
},
decryptionOptions: {
...new AccountDecryptionOptions(),
...init?.decryptionOptions,
},
adminAuthRequest: init?.adminAuthRequest,
});
}
@@ -375,7 +277,6 @@ export class Account {
profile: AccountProfile.fromJSON(json?.profile),
settings: AccountSettings.fromJSON(json?.settings),
tokens: AccountTokens.fromJSON(json?.tokens),
decryptionOptions: AccountDecryptionOptions.fromJSON(json?.decryptionOptions),
adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest),
});
}

View File

@@ -34,12 +34,7 @@ import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums";
import { StateFactory } from "../factories/state-factory";
import { Utils } from "../misc/utils";
import { ServerConfigData } from "../models/data/server-config.data";
import {
Account,
AccountData,
AccountDecryptionOptions,
AccountSettings,
} from "../models/domain/account";
import { Account, AccountData, AccountSettings } from "../models/domain/account";
import { EncString } from "../models/domain/enc-string";
import { GlobalState } from "../models/domain/global-state";
import { State } from "../models/domain/state";
@@ -817,37 +812,6 @@ export class StateService<
await this.saveAccount(account, options);
}
async getAccountDecryptionOptions(
options?: StorageOptions,
): Promise<AccountDecryptionOptions | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return null;
}
const account = await this.getAccount(options);
return account?.decryptionOptions as AccountDecryptionOptions;
}
async setAccountDecryptionOptions(
value: AccountDecryptionOptions,
options?: StorageOptions,
): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return;
}
const account = await this.getAccount(options);
account.decryptionOptions = value;
await this.saveAccount(account, options);
}
async getEmail(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))

View File

@@ -44,6 +44,7 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
});
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
// Autofill

View File

@@ -1,5 +1,10 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, of } from "rxjs";
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "../../admin-console/models/domain/policy";
@@ -8,12 +13,12 @@ import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { StateService } from "../../platform/abstractions/state.service";
import { BiometricStateService } from "../../platform/biometrics/biometric-state.service";
import { AccountDecryptionOptions } from "../../platform/models/domain/account";
import { EncString } from "../../platform/models/domain/enc-string";
import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service";
describe("VaultTimeoutSettingsService", () => {
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let cryptoService: MockProxy<CryptoService>;
let tokenService: MockProxy<TokenService>;
let policyService: MockProxy<PolicyService>;
@@ -21,12 +26,26 @@ describe("VaultTimeoutSettingsService", () => {
const biometricStateService = mock<BiometricStateService>();
let service: VaultTimeoutSettingsService;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
cryptoService = mock<CryptoService>();
tokenService = mock<TokenService>();
policyService = mock<PolicyService>();
stateService = mock<StateService>();
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
map((options) => options?.hasMasterPassword ?? false),
);
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
service = new VaultTimeoutSettingsService(
userDecryptionOptionsService,
cryptoService,
tokenService,
policyService,
@@ -49,9 +68,7 @@ describe("VaultTimeoutSettingsService", () => {
});
it("contains Lock when the user has a master password", async () => {
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: true }),
);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
const result = await firstValueFrom(service.availableVaultTimeoutActions$());
@@ -83,9 +100,7 @@ describe("VaultTimeoutSettingsService", () => {
});
it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => {
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false }));
stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null);
stateService.getProtectedPin.mockResolvedValue(null);
biometricStateService.biometricUnlockEnabled$ = of(false);
@@ -107,9 +122,7 @@ describe("VaultTimeoutSettingsService", () => {
`(
"returns $expected when policy is $policy, and user preference is $userPreference",
async ({ policy, userPreference, expected }) => {
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: true }),
);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.getAll$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
);
@@ -136,8 +149,8 @@ describe("VaultTimeoutSettingsService", () => {
"returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference",
async ({ unlockMethod, policy, userPreference, expected }) => {
biometricStateService.biometricUnlockEnabled$ = of(unlockMethod);
stateService.getAccountDecryptionOptions.mockResolvedValue(
new AccountDecryptionOptions({ hasMasterPassword: false }),
userDecryptionOptionsSubject.next(
new UserDecryptionOptions({ hasMasterPassword: false }),
);
policyService.getAll$.mockReturnValue(
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),

View File

@@ -1,5 +1,7 @@
import { defer, firstValueFrom } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";
@@ -19,6 +21,7 @@ export type PinLockType = "DISABLED" | "PERSISTANT" | "TRANSIENT";
export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction {
constructor(
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private cryptoService: CryptoService,
private tokenService: TokenService,
private policyService: PolicyService,
@@ -174,12 +177,15 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
private async userHasMasterPassword(userId: string): Promise<boolean> {
const acctDecryptionOpts = await this.stateService.getAccountDecryptionOptions({
userId: userId,
});
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
if (acctDecryptionOpts?.hasMasterPassword != undefined) {
return acctDecryptionOpts.hasMasterPassword;
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
}
}
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
}
}

View File

@@ -39,6 +39,7 @@ import { OrganizationMigrator } from "./migrations/40-move-organization-state-to
import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider";
import { EnableFaviconMigrator } from "./migrations/42-move-enable-favicon-to-domain-settings-state-provider";
import { AutoConfirmFingerPrintsMigrator } from "./migrations/43-move-auto-confirm-finger-prints-to-state-provider";
import { UserDecryptionOptionsMigrator } from "./migrations/44-move-user-decryption-options-to-state-provider";
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";
@@ -47,7 +48,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 43;
export const CURRENT_VERSION = 44;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -92,7 +93,8 @@ export function createMigrationBuilder() {
.with(OrganizationMigrator, 39, 40)
.with(EventCollectionMigrator, 40, 41)
.with(EnableFaviconMigrator, 41, 42)
.with(AutoConfirmFingerPrintsMigrator, 42, CURRENT_VERSION);
.with(AutoConfirmFingerPrintsMigrator, 42, 43)
.with(UserDecryptionOptionsMigrator, 43, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,238 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { UserDecryptionOptionsMigrator } from "./44-move-user-decryption-options-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_decryptionOptions_userDecryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
user_SecondAccount_decryptionOptions_userDecryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
user_ThirdAccount_decryptionOptions_userDecryptionOptions: {},
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("UserDecryptionOptionsMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: UserDecryptionOptionsMigrator;
const keyDefinitionLike = {
key: "decryptionOptions",
stateDefinition: {
name: "userDecryptionOptions",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 43);
sut = new UserDecryptionOptionsMigrator(43, 44);
});
it("should remove decryptionOptions from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set decryptionOptions provider value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", keyDefinitionLike, {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
});
expect(helper.setToUser).toHaveBeenCalledWith("SecondAccount", keyDefinitionLike, {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 44);
sut = new UserDecryptionOptionsMigrator(43, 44);
});
it.each(["FirstAccount", "SecondAccount", "ThirdAccount"])(
"should null out new values",
async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
},
);
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any());
});
});
});

View File

@@ -0,0 +1,57 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type DecryptionOptionsType = {
hasMasterPassword: boolean;
trustedDeviceOption?: {
hasAdminApproval: boolean;
hasLoginApprovingDevice: boolean;
hasManageResetPasswordPermission: boolean;
};
keyConnectorOption?: {
keyConnectorUrl: string;
};
};
type ExpectedAccountType = {
decryptionOptions?: DecryptionOptionsType;
};
const USER_DECRYPTION_OPTIONS: KeyDefinitionLike = {
key: "decryptionOptions",
stateDefinition: {
name: "userDecryptionOptions",
},
};
export class UserDecryptionOptionsMigrator extends Migrator<43, 44> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.decryptionOptions;
if (value != null) {
await helper.setToUser(userId, USER_DECRYPTION_OPTIONS, value);
delete account.decryptionOptions;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value: DecryptionOptionsType = await helper.getFromUser(
userId,
USER_DECRYPTION_OPTIONS,
);
if (account) {
account.decryptionOptions = Object.assign(account.decryptionOptions, value);
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_DECRYPTION_OPTIONS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -1,3 +1,7 @@
import { firstValueFrom } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "../../../abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
@@ -24,7 +28,6 @@ import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { sequentialize } from "../../../platform/misc/sequentialize";
import { AccountDecryptionOptions } from "../../../platform/models/domain/account";
import { SendData } from "../../../tools/send/models/data/send.data";
import { SendResponse } from "../../../tools/send/models/response/send.response";
import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction";
@@ -62,6 +65,7 @@ export class SyncService implements SyncServiceAbstraction {
private folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
private sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (expired: boolean) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -353,19 +357,12 @@ export class SyncService implements SyncServiceAbstraction {
);
}
const acctDecryptionOpts: AccountDecryptionOptions =
await this.stateService.getAccountDecryptionOptions();
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
// Account decryption options should never be null or undefined b/c it is always initialized
// during the processing of the ID token response, but there might be a state issue
// where it is being overwritten with undefined affecting browser extension + FireFox users.
// TODO: Consider removing this once we figure out the root cause of the state issue or after the state provider refactor.
if (acctDecryptionOpts === null || acctDecryptionOpts === undefined) {
if (userDecryptionOptions === null || userDecryptionOptions === undefined) {
this.logService.error("Sync: Account decryption options are null or undefined.");
// Early return as a bandaid to allow the rest of the sync to continue so users can access
// their data that they might have added from another device.
// Otherwise, trying to access properties on undefined below will throw an error.
return;
}
// Even though TDE users should only be in a single org (per single org policy), check
@@ -384,8 +381,8 @@ export class SyncService implements SyncServiceAbstraction {
}
if (
acctDecryptionOpts.trustedDeviceOption !== undefined &&
!acctDecryptionOpts.hasMasterPassword &&
userDecryptionOptions.trustedDeviceOption !== undefined &&
!userDecryptionOptions.hasMasterPassword &&
hasManageResetPasswordPermission
) {
// TDE user w/out MP went from having no password reset permission to having it.