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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export class KeyConnectorUserDecryptionOption {
|
||||
constructor(public keyConnectorUrl: string) {}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export class TrustedDeviceUserDecryptionOption {
|
||||
constructor(
|
||||
public hasAdminApproval: boolean,
|
||||
public hasLoginApprovingDevice: boolean,
|
||||
public hasManageResetPasswordPermission: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[])),
|
||||
|
||||
@@ -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$);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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))]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user