1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-08-27 23:31:41 +02:00

[PM-6426] Merging main into branch

This commit is contained in:
Cesar Gonzalez 2024-04-01 15:18:50 -05:00
commit 83cb105b49
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
50 changed files with 1186 additions and 565 deletions

View File

@ -24,6 +24,7 @@ import {
} from "../../../platform/background/service-factories/state-service.factory";
import { AccountServiceInitOptions, accountServiceFactory } from "./account-service.factory";
import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory";
type AuthServiceFactoryOptions = FactoryOptions;
@ -32,7 +33,8 @@ export type AuthServiceInitOptions = AuthServiceFactoryOptions &
MessagingServiceInitOptions &
CryptoServiceInitOptions &
ApiServiceInitOptions &
StateServiceInitOptions;
StateServiceInitOptions &
TokenServiceInitOptions;
export function authServiceFactory(
cache: { authService?: AbstractAuthService } & CachedServices,
@ -49,6 +51,7 @@ export function authServiceFactory(
await cryptoServiceFactory(cache, opts),
await apiServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await tokenServiceFactory(cache, opts),
),
);
}

View File

@ -39,9 +39,13 @@ import {
platformUtilsServiceFactory,
} from "../../../platform/background/service-factories/platform-utils-service.factory";
import {
StateServiceInitOptions,
stateServiceFactory,
} from "../../../platform/background/service-factories/state-service.factory";
StateProviderInitOptions,
stateProviderFactory,
} from "../../../platform/background/service-factories/state-provider.factory";
import {
SecureStorageServiceInitOptions,
secureStorageServiceFactory,
} from "../../../platform/background/service-factories/storage-service.factory";
import {
UserDecryptionOptionsServiceInitOptions,
@ -55,11 +59,12 @@ export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactor
CryptoFunctionServiceInitOptions &
CryptoServiceInitOptions &
EncryptServiceInitOptions &
StateServiceInitOptions &
AppIdServiceInitOptions &
DevicesApiServiceInitOptions &
I18nServiceInitOptions &
PlatformUtilsServiceInitOptions &
StateProviderInitOptions &
SecureStorageServiceInitOptions &
UserDecryptionOptionsServiceInitOptions;
export function deviceTrustCryptoServiceFactory(
@ -76,11 +81,12 @@ export function deviceTrustCryptoServiceFactory(
await cryptoFunctionServiceFactory(cache, opts),
await cryptoServiceFactory(cache, opts),
await encryptServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await appIdServiceFactory(cache, opts),
await devicesApiServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
await platformUtilsServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
await secureStorageServiceFactory(cache, opts),
await userDecryptionOptionsServiceFactory(cache, opts),
),
);

View File

@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@ -62,6 +63,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService: PinCryptoServiceAbstraction,
private routerService: BrowserRouterService,
biometricStateService: BiometricStateService,
accountService: AccountService,
) {
super(
router,
@ -84,6 +86,7 @@ export class LockComponent extends BaseLockComponent {
userVerificationService,
pinCryptoService,
biometricStateService,
accountService,
);
this.successRoute = "/tabs/current";
this.isInitialLockScreen = (window as any).previousPopupUrl == null;

View File

@ -9,6 +9,7 @@ import {
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
@ -49,6 +50,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
authRequestService: AuthRequestServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction,
accountService: AccountService,
private location: Location,
) {
super(
@ -70,6 +72,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {
deviceTrustCryptoService,
authRequestService,
loginStrategyService,
accountService,
);
super.onSuccessfulLogin = async () => {
await syncService.fullSync(true);

View File

@ -563,11 +563,12 @@ export default class MainBackground {
this.cryptoFunctionService,
this.cryptoService,
this.encryptService,
this.stateService,
this.appIdService,
this.devicesApiService,
this.i18nService,
this.platformUtilsService,
this.stateProvider,
this.secureStorageService,
this.userDecryptionOptionsService,
);
@ -586,6 +587,7 @@ export default class MainBackground {
this.cryptoService,
this.apiService,
this.stateService,
this.tokenService,
);
this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService(

View File

@ -71,7 +71,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "6.1.13",
"tldts": "6.1.16",
"zxcvbn": "4.4.2"
}
}

View File

@ -455,11 +455,12 @@ export class Main {
this.cryptoFunctionService,
this.cryptoService,
this.encryptService,
this.stateService,
this.appIdService,
this.devicesApiService,
this.i18nService,
this.platformUtilsService,
this.stateProvider,
this.secureStorageService,
this.userDecryptionOptionsService,
);
@ -503,6 +504,7 @@ export class Main {
this.cryptoService,
this.apiService,
this.stateService,
this.tokenService,
);
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
@ -712,12 +714,6 @@ export class Main {
this.containerService.attachToGlobal(global);
await this.i18nService.init();
this.twoFactorService.init();
const installedVersion = await this.stateService.getInstalledVersion();
const currentVersion = await this.platformUtilsService.getApplicationVersion();
if (installedVersion == null || installedVersion !== currentVersion) {
await this.stateService.setInstalledVersion(currentVersion);
}
}
}

View File

@ -53,18 +53,6 @@ export class InitService {
const htmlEl = this.win.document.documentElement;
htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString());
this.themingService.applyThemeChangesTo(this.document);
let installAction = null;
const installedVersion = await this.stateService.getInstalledVersion();
const currentVersion = await this.platformUtilsService.getApplicationVersion();
if (installedVersion == null) {
installAction = "install";
} else if (installedVersion !== currentVersion) {
installAction = "update";
}
if (installAction != null) {
await this.stateService.setInstalledVersion(currentVersion);
}
const containerService = new ContainerService(this.cryptoService, this.encryptService);
containerService.attachToGlobal(this.win);

View File

@ -12,6 +12,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@ -23,7 +24,10 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { LockComponent } from "./lock.component";
@ -49,6 +53,9 @@ describe("LockComponent", () => {
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
let activatedRouteMock: MockProxy<ActivatedRoute>;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(async () => {
stateServiceMock = mock<StateService>();
stateServiceMock.activeAccount$ = of(null);
@ -147,6 +154,10 @@ describe("LockComponent", () => {
provide: BiometricStateService,
useValue: biometricStateService,
},
{
provide: AccountService,
useValue: accountService,
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

View File

@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DeviceType } from "@bitwarden/common/enums";
@ -59,6 +60,7 @@ export class LockComponent extends BaseLockComponent {
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
biometricStateService: BiometricStateService,
accountService: AccountService,
) {
super(
router,
@ -81,6 +83,7 @@ export class LockComponent extends BaseLockComponent {
userVerificationService,
pinCryptoService,
biometricStateService,
accountService,
);
}

View File

@ -10,6 +10,7 @@ import {
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
@ -57,6 +58,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
authRequestService: AuthRequestServiceAbstraction,
loginStrategyService: LoginStrategyServiceAbstraction,
accountService: AccountService,
private location: Location,
) {
super(
@ -78,6 +80,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {
deviceTrustCryptoService,
authRequestService,
loginStrategyService,
accountService,
);
super.onSuccessfulLogin = () => {

View File

@ -1,47 +1,12 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service";
import { DeviceKey } from "@bitwarden/common/types/key";
import { Account } from "../../models/account";
export class ElectronStateService extends BaseStateService<GlobalState, Account> {
private partialKeys = {
deviceKey: "_deviceKey",
};
async addAccount(account: Account) {
// Apply desktop overides to default account values
account = new Account(account);
await super.addAccount(account);
}
override async getDeviceKey(options?: StorageOptions): Promise<DeviceKey | null> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
const b64DeviceKey = await this.secureStorageService.get<string>(
`${options.userId}${this.partialKeys.deviceKey}`,
options,
);
if (b64DeviceKey == null) {
return null;
}
return new SymmetricCryptoKey(Utils.fromB64ToArray(b64DeviceKey)) as DeviceKey;
}
override async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(this.partialKeys.deviceKey, value.keyB64, options);
}
}

View File

@ -9,7 +9,11 @@ module.exports = {
...sharedConfig,
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",
}),
moduleNameMapper: pathsToModuleNameMapper(
// lets us use @bitwarden/common/spec in web tests
{ "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
{
prefix: "<rootDir>/",
},
),
};

View File

@ -6,10 +6,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@ -41,6 +44,9 @@ describe("KeyRotationService", () => {
let mockStateService: MockProxy<StateService>;
let mockConfigService: MockProxy<ConfigService>;
const mockUserId = Utils.newGuid() as UserId;
const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeAll(() => {
mockApiService = mock<UserKeyRotationApiService>();
mockCipherService = mock<CipherService>();
@ -65,6 +71,7 @@ describe("KeyRotationService", () => {
mockCryptoService,
mockEncryptService,
mockStateService,
mockAccountService,
mockConfigService,
);
});

View File

@ -1,6 +1,7 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -34,6 +35,7 @@ export class UserKeyRotationService {
private cryptoService: CryptoService,
private encryptService: EncryptService,
private stateService: StateService,
private accountService: AccountService,
private configService: ConfigService,
) {}
@ -90,7 +92,12 @@ export class UserKeyRotationService {
await this.rotateUserKeyAndEncryptedDataLegacy(request);
}
await this.deviceTrustCryptoService.rotateDevicesTrust(newUserKey, masterPasswordHash);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustCryptoService.rotateDevicesTrust(
activeAccount.id,
newUserKey,
masterPasswordHash,
);
}
private async encryptPrivateKey(newUserKey: UserKey): Promise<EncryptedString | null> {

View File

@ -8,6 +8,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@ -47,6 +48,7 @@ export class LockComponent extends BaseLockComponent {
userVerificationService: UserVerificationService,
pinCryptoService: PinCryptoServiceAbstraction,
biometricStateService: BiometricStateService,
accountService: AccountService,
) {
super(
router,
@ -69,6 +71,7 @@ export class LockComponent extends BaseLockComponent {
userVerificationService,
pinCryptoService,
biometricStateService,
accountService,
);
}

View File

@ -22,6 +22,7 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
@ -34,6 +35,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
enum State {
NewUser,
@ -65,6 +67,8 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
protected data?: Data;
protected loading = true;
activeAccountId: UserId;
// Remember device means for the user to trust the device
rememberDeviceForm = this.formBuilder.group({
rememberDevice: [true],
@ -94,10 +98,12 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected accountService: AccountService,
) {}
async ngOnInit() {
this.loading = true;
this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.setupRememberDeviceValueChanges();
@ -150,7 +156,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
}
private async setRememberDeviceDefaultValue() {
const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice();
const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice(
this.activeAccountId,
);
const rememberDevice = rememberDeviceFromState ?? true;
@ -161,7 +169,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
this.rememberDevice.valueChanges
.pipe(
switchMap((value) =>
defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value)),
defer(() =>
this.deviceTrustCryptoService.setShouldTrustDevice(this.activeAccountId, value),
),
),
takeUntil(this.destroy$),
)
@ -278,7 +288,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
await this.passwordResetEnrollmentService.enroll(this.data.organizationId);
if (this.rememberDeviceForm.value.rememberDevice) {
await this.deviceTrustCryptoService.trustDevice();
await this.deviceTrustCryptoService.trustDevice(this.activeAccountId);
}
} catch (error) {
this.validationService.showError(error);

View File

@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@ -75,6 +76,7 @@ export class LockComponent implements OnInit, OnDestroy {
protected userVerificationService: UserVerificationService,
protected pinCryptoService: PinCryptoServiceAbstraction,
protected biometricStateService: BiometricStateService,
protected accountService: AccountService,
) {}
async ngOnInit() {
@ -269,7 +271,8 @@ export class LockComponent implements OnInit, OnDestroy {
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustCryptoService.trustDeviceIfRequired();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id);
await this.doContinue(evaluatePasswordAfterUnlock);
}

View File

@ -1,6 +1,6 @@
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { IsActiveMatchOptions, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import {
AuthRequestLoginCredentials,
@ -9,6 +9,7 @@ import {
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
@ -87,6 +88,7 @@ export class LoginViaAuthRequestComponent
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
private accountService: AccountService,
) {
super(environmentService, i18nService, platformUtilsService);
@ -388,7 +390,8 @@ export class LoginViaAuthRequestComponent
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustCryptoService.trustDeviceIfRequired();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id);
// TODO: don't forget to use auto enrollment service everywhere we trust device

View File

@ -0,0 +1,14 @@
import { ErrorHandler, Injectable } from "@angular/core";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@Injectable()
export class LoggingErrorHandler extends ErrorHandler {
constructor(private readonly logService: LogService) {
super();
}
override handleError(error: any): void {
this.logService.error(error);
}
}

View File

@ -1,4 +1,4 @@
import { LOCALE_ID, NgModule } from "@angular/core";
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import {
AuthRequestServiceAbstraction,
@ -239,6 +239,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { BroadcasterService } from "../platform/services/broadcaster.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
@ -350,6 +351,7 @@ const safeProviders: SafeProvider[] = [
CryptoServiceAbstraction,
ApiServiceAbstraction,
StateServiceAbstraction,
TokenService,
],
}),
safeProvider({
@ -919,11 +921,12 @@ const safeProviders: SafeProvider[] = [
CryptoFunctionServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
StateServiceAbstraction,
AppIdServiceAbstraction,
DevicesApiServiceAbstraction,
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
StateProvider,
SECURE_STORAGE,
UserDecryptionOptionsServiceAbstraction,
],
}),
@ -1078,6 +1081,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultOrganizationManagementPreferencesService,
deps: [StateProvider],
}),
safeProvider({
provide: ErrorHandler,
useClass: LoggingErrorHandler,
deps: [LogService],
}),
safeProvider({
provide: TaskSchedulerService,
deps: [],

View File

@ -16,6 +16,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
@ -128,8 +129,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey);
} else {
await this.trySetUserKeyWithMasterKey();
const userId = (await this.stateService.getUserId()) as UserId;
// Establish trust if required after setting user key
await this.deviceTrustCryptoService.trustDeviceIfRequired();
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
}
}

View File

@ -36,7 +36,7 @@ import {
PasswordStrengthService,
} from "@bitwarden/common/tools/password-strength";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { LoginStrategyServiceAbstraction } from "../abstractions";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
@ -215,29 +215,6 @@ describe("LoginStrategy", () => {
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
});
it("persists a device key for trusted device encryption when it exists on login", async () => {
// Arrange
const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
const deviceKey = new SymmetricCryptoKey(
new Uint8Array(userKeyBytesLength).buffer as CsprngArray,
) as DeviceKey;
stateService.getDeviceKey.mockResolvedValue(deviceKey);
const accountKeys = new AccountKeys();
accountKeys.deviceKey = deviceKey;
// Act
await passwordLoginStrategy.logIn(credentials);
// Assert
expect(stateService.addAccount).toHaveBeenCalledWith(
expect.objectContaining({ keys: accountKeys }),
);
});
it("builds AuthResult", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true;

View File

@ -26,7 +26,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import {
AccountKeys,
Account,
AccountProfile,
AccountTokens,
@ -160,18 +159,8 @@ export abstract class LoginStrategy {
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<void> {
const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken);
// Must persist existing device key if it exists for trusted device decryption to work
// However, we must provide a user id so that the device key can be retrieved
// as the state service won't have an active account at this point in time
// even though the data exists in local storage.
const userId = accountInformation.sub;
const deviceKey = await this.stateService.getDeviceKey({ userId });
const accountKeys = new AccountKeys();
if (deviceKey) {
accountKeys.deviceKey = deviceKey;
}
// If you don't persist existing admin auth requests on login, they will get deleted.
const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId });
@ -204,7 +193,6 @@ export abstract class LoginStrategy {
tokens: {
...new AccountTokens(),
},
keys: accountKeys,
adminAuthRequest: adminAuthRequest?.toJSON(),
}),
);

View File

@ -20,6 +20,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
InternalUserDecryptionOptionsServiceAbstraction,
@ -284,7 +285,8 @@ export class SsoLoginStrategy extends LoginStrategy {
if (await this.cryptoService.hasUserKey()) {
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
await this.deviceTrustCryptoService.trustDeviceIfRequired();
const userId = (await this.stateService.getUserId()) as UserId;
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
// if we successfully decrypted the user key, we can delete the admin auth request out of state
// TODO: eventually we post and clean up DB as well once consumed on client
@ -298,7 +300,9 @@ export class SsoLoginStrategy extends LoginStrategy {
private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise<void> {
const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption;
const deviceKey = await this.deviceTrustCryptoService.getDeviceKey();
const userId = (await this.stateService.getUserId()) as UserId;
const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(userId);
const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey;
const encUserKey = trustedDeviceOption?.encryptedUserKey;
@ -307,6 +311,7 @@ export class SsoLoginStrategy extends LoginStrategy {
}
const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
userId,
encDevicePrivateKey,
encUserKey,
deviceKey,

View File

@ -1,10 +1,17 @@
import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
import { AuthenticationStatus } from "../enums/authentication-status";
export abstract class AuthService {
/** Authentication status for the active user */
abstract activeAccountStatus$: Observable<AuthenticationStatus>;
/**
* Returns an observable authentication status for the given user id.
* @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut`
* @param userId The user id to check for an access token.
*/
abstract authStatusFor$(userId: UserId): Observable<AuthenticationStatus>;
/** @deprecated use {@link activeAccountStatus$} instead */
abstract getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
abstract logOut: (callback: () => void) => void;

View File

@ -1,6 +1,7 @@
import { Observable } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
@ -10,17 +11,24 @@ export abstract class DeviceTrustCryptoServiceAbstraction {
* @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
*/
getShouldTrustDevice: () => Promise<boolean | null>;
setShouldTrustDevice: (value: boolean) => Promise<void>;
getShouldTrustDevice: (userId: UserId) => Promise<boolean | null>;
setShouldTrustDevice: (userId: UserId, value: boolean) => Promise<void>;
trustDeviceIfRequired: () => Promise<void>;
trustDeviceIfRequired: (userId: UserId) => Promise<void>;
trustDevice: () => Promise<DeviceResponse>;
getDeviceKey: () => Promise<DeviceKey>;
trustDevice: (userId: UserId) => Promise<DeviceResponse>;
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
getDeviceKey: (userId: UserId) => Promise<DeviceKey | null>;
decryptUserKeyWithDeviceKey: (
userId: UserId,
encryptedDevicePrivateKey: EncString,
encryptedUserKey: EncString,
deviceKey?: DeviceKey,
deviceKey: DeviceKey,
) => Promise<UserKey | null>;
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
rotateDevicesTrust: (
userId: UserId,
newUserKey: UserKey,
masterPasswordHash: string,
) => Promise<void>;
}

View File

@ -1,8 +1,15 @@
import { Observable } from "rxjs";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { UserId } from "../../types/guid";
import { DecodedAccessToken } from "../services/token.service";
export abstract class TokenService {
/**
* Returns an observable that emits a boolean indicating whether the user has an access token.
* @param userId The user id to check for an access token.
*/
abstract hasAccessToken$(userId: UserId): Observable<boolean>;
/**
* Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk
* based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id.

View File

@ -1,13 +1,21 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import {
FakeAccountService,
makeStaticByteArray,
mockAccountServiceWith,
trackEmissions,
} from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../types/guid";
import { UserKey } from "../../types/key";
import { TokenService } from "../abstractions/token.service";
import { AuthenticationStatus } from "../enums/authentication-status";
import { AuthService } from "./auth.service";
@ -20,15 +28,18 @@ describe("AuthService", () => {
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
let stateService: MockProxy<StateService>;
let tokenService: MockProxy<TokenService>;
const userId = Utils.newGuid() as UserId;
const userKey = new SymmetricCryptoKey(makeStaticByteArray(32) as Uint8Array) as UserKey;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
messagingService = mock<MessagingService>();
cryptoService = mock<CryptoService>();
apiService = mock<ApiService>();
stateService = mock<StateService>();
messagingService = mock();
cryptoService = mock();
apiService = mock();
stateService = mock();
tokenService = mock();
sut = new AuthService(
accountService,
@ -36,26 +47,115 @@ describe("AuthService", () => {
cryptoService,
apiService,
stateService,
tokenService,
);
});
describe("activeAccountStatus$", () => {
test.each([
AuthenticationStatus.LoggedOut,
AuthenticationStatus.Locked,
AuthenticationStatus.Unlocked,
])(
`should emit %p when activeAccount$ emits an account with %p auth status`,
async (status) => {
accountService.activeAccountSubject.next({
id: userId,
email: "email",
name: "name",
status,
});
const accountInfo = {
status: AuthenticationStatus.Unlocked,
id: userId,
email: "email",
name: "name",
};
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(status);
},
);
beforeEach(() => {
accountService.activeAccountSubject.next(accountInfo);
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
});
it("emits LoggedOut when there is no active account", async () => {
accountService.activeAccountSubject.next(undefined);
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
AuthenticationStatus.LoggedOut,
);
});
it("emits LoggedOut when there is no access token", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(false));
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
AuthenticationStatus.LoggedOut,
);
});
it("emits LoggedOut when there is no access token but has a user key", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(false));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
AuthenticationStatus.LoggedOut,
);
});
it("emits Locked when there is an access token and no user key", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Locked);
});
it("emits Unlocked when there is an access token and user key", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Unlocked);
});
it("follows the current active user", async () => {
const accountInfo2 = {
status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId,
email: "email2",
name: "name2",
};
const emissions = trackEmissions(sut.activeAccountStatus$);
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
accountService.activeAccountSubject.next(accountInfo2);
expect(emissions).toEqual([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked]);
});
});
describe("authStatusFor$", () => {
beforeEach(() => {
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
});
it("emits LoggedOut when userId is null", async () => {
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
AuthenticationStatus.LoggedOut,
);
});
it("emits LoggedOut when there is no access token", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(false));
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
AuthenticationStatus.LoggedOut,
);
});
it("emits Locked when there is an access token and no user key", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(AuthenticationStatus.Locked);
});
it("emits Unlocked when there is an access token and user key", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(true));
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
AuthenticationStatus.Unlocked,
);
});
});
});

View File

@ -1,12 +1,22 @@
import { Observable, distinctUntilChanged, map, shareReplay } from "rxjs";
import {
Observable,
combineLatest,
distinctUntilChanged,
map,
of,
shareReplay,
switchMap,
} from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { StateService } from "../../platform/abstractions/state.service";
import { KeySuffixOptions } from "../../platform/enums";
import { UserId } from "../../types/guid";
import { AccountService } from "../abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { AuthenticationStatus } from "../enums/authentication-status";
export class AuthService implements AuthServiceAbstraction {
@ -18,9 +28,36 @@ export class AuthService implements AuthServiceAbstraction {
protected cryptoService: CryptoService,
protected apiService: ApiService,
protected stateService: StateService,
private tokenService: TokenService,
) {
this.activeAccountStatus$ = this.accountService.activeAccount$.pipe(
map((account) => account.status),
map((account) => account?.id),
switchMap((userId) => {
return this.authStatusFor$(userId);
}),
);
}
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
if (userId == null) {
return of(AuthenticationStatus.LoggedOut);
}
return combineLatest([
this.cryptoService.getInMemoryUserKeyFor$(userId),
this.tokenService.hasAccessToken$(userId),
]).pipe(
map(([userKey, hasAccessToken]) => {
if (!hasAccessToken) {
return AuthenticationStatus.LoggedOut;
}
if (!userKey) {
return AuthenticationStatus.Locked;
}
return AuthenticationStatus.Unlocked;
}),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);

View File

@ -9,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserKey, DeviceKey } from "../../types/key";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
@ -22,7 +26,25 @@ import {
UpdateDevicesTrustRequest,
} from "../models/request/update-devices-trust.request";
/** Uses disk storage so that the device key can persist after log out and tab removal. */
export const DEVICE_KEY = new KeyDefinition<DeviceKey>(DEVICE_TRUST_DISK_LOCAL, "deviceKey", {
deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey,
});
/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */
export const SHOULD_TRUST_DEVICE = new KeyDefinition<boolean>(
DEVICE_TRUST_DISK_LOCAL,
"shouldTrustDevice",
{
deserializer: (shouldTrustDevice) => shouldTrustDevice,
},
);
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
private readonly platformSupportsSecureStorage =
this.platformUtilsService.supportsSecureStorage();
private readonly deviceKeySecureStorageKey: string = "_deviceKey";
supportsDeviceTrust$: Observable<boolean>;
constructor(
@ -30,11 +52,12 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
private cryptoFunctionService: CryptoFunctionService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private stateService: StateService,
private appIdService: AppIdService,
private devicesApiService: DevicesApiServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private stateProvider: StateProvider,
private secureStorageService: AbstractStorageService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
@ -46,24 +69,44 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
* @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
*/
async getShouldTrustDevice(): Promise<boolean> {
return await this.stateService.getShouldTrustDevice();
async getShouldTrustDevice(userId: UserId): Promise<boolean> {
if (!userId) {
throw new Error("UserId is required. Cannot get should trust device.");
}
const shouldTrustDevice = await firstValueFrom(
this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId),
);
return shouldTrustDevice;
}
async setShouldTrustDevice(value: boolean): Promise<void> {
await this.stateService.setShouldTrustDevice(value);
async setShouldTrustDevice(userId: UserId, value: boolean): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set should trust device.");
}
await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId);
}
async trustDeviceIfRequired(): Promise<void> {
const shouldTrustDevice = await this.getShouldTrustDevice();
async trustDeviceIfRequired(userId: UserId): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot trust device if required.");
}
const shouldTrustDevice = await this.getShouldTrustDevice(userId);
if (shouldTrustDevice) {
await this.trustDevice();
await this.trustDevice(userId);
// reset the trust choice
await this.setShouldTrustDevice(false);
await this.setShouldTrustDevice(userId, false);
}
}
async trustDevice(): Promise<DeviceResponse> {
async trustDevice(userId: UserId): Promise<DeviceResponse> {
if (!userId) {
throw new Error("UserId is required. Cannot trust device.");
}
// Attempt to get user key
const userKey: UserKey = await this.cryptoService.getUserKey();
@ -104,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
);
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(deviceKey);
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
return deviceResponse;
}
async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise<void> {
const currentDeviceKey = await this.getDeviceKey();
async rotateDevicesTrust(
userId: UserId,
newUserKey: UserKey,
masterPasswordHash: string,
): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot rotate device's trust.");
}
const currentDeviceKey = await this.getDeviceKey(userId);
if (currentDeviceKey == null) {
// If the current device doesn't have a device key available to it, then we can't
// rotate any trust at all, so early return.
@ -165,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier);
}
async getDeviceKey(): Promise<DeviceKey> {
return await this.stateService.getDeviceKey();
async getDeviceKey(userId: UserId): Promise<DeviceKey | null> {
if (!userId) {
throw new Error("UserId is required. Cannot get device key.");
}
if (this.platformSupportsSecureStorage) {
const deviceKeyB64 = await this.secureStorageService.get<
ReturnType<SymmetricCryptoKey["toJSON"]>
>(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey;
return deviceKey;
}
const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId));
return deviceKey;
}
private async setDeviceKey(deviceKey: DeviceKey | null): Promise<void> {
await this.stateService.setDeviceKey(deviceKey);
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set device key.");
}
if (this.platformSupportsSecureStorage) {
await this.secureStorageService.save<DeviceKey>(
`${userId}${this.deviceKeySecureStorageKey}`,
deviceKey,
this.getSecureStorageOptions(userId),
);
return;
}
await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId);
}
private async makeDeviceKey(): Promise<DeviceKey> {
// Create 512-bit device key
return (await this.keyGenerationService.createKey(512)) as DeviceKey;
const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey;
return deviceKey;
}
async decryptUserKeyWithDeviceKey(
userId: UserId,
encryptedDevicePrivateKey: EncString,
encryptedUserKey: EncString,
deviceKey?: DeviceKey,
deviceKey: DeviceKey,
): Promise<UserKey | null> {
// If device key provided use it, otherwise try to retrieve from storage
deviceKey ||= await this.getDeviceKey();
if (!userId) {
throw new Error("UserId is required. Cannot decrypt user key with device key.");
}
if (!deviceKey) {
// User doesn't have a device key anymore so device is untrusted
@ -207,9 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
return new SymmetricCryptoKey(userKey) as UserKey;
} catch (e) {
// If either decryption effort fails, we want to remove the device key
await this.setDeviceKey(null);
await this.setDeviceKey(userId, null);
return null;
}
}
private getSecureStorageOptions(userId: UserId): StorageOptions {
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: userId,
};
}
}

View File

@ -4,6 +4,9 @@ import { BehaviorSubject, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { DeviceType } from "../../enums";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
@ -12,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
import { Utils } from "../../platform/misc/utils";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
import {
SHOULD_TRUST_DEVICE,
DEVICE_KEY,
DeviceTrustCryptoService,
} from "./device-trust-crypto.service.implementation";
describe("deviceTrustCryptoService", () => {
let deviceTrustCryptoService: DeviceTrustCryptoService;
@ -32,33 +43,34 @@ describe("deviceTrustCryptoService", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
const stateService = mock<StateService>();
const appIdService = mock<AppIdService>();
const devicesApiService = mock<DevicesApiServiceAbstraction>();
const i18nService = mock<I18nService>();
const platformUtilsService = mock<PlatformUtilsService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const secureStorageService = mock<AbstractStorageService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
let stateProvider: FakeStateProvider;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
const deviceKeyPartialSecureStorageKey = "_deviceKey";
const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`;
const secureStorageOptions: StorageOptions = {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: mockUserId,
};
beforeEach(() => {
jest.clearAllMocks();
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
deviceTrustCryptoService = new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
cryptoService,
encryptService,
stateService,
appIdService,
devicesApiService,
i18nService,
platformUtilsService,
userDecryptionOptionsService,
);
const supportsSecureStorage = false; // default to false; tests will override as needed
// By default all the tests will have a mocked active user in state provider.
deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage);
});
it("instantiates", () => {
@ -67,27 +79,26 @@ describe("deviceTrustCryptoService", () => {
describe("User Trust Device Choice For Decryption", () => {
describe("getShouldTrustDevice", () => {
it("gets the user trust device choice for decryption from the state service", async () => {
const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice");
it("gets the user trust device choice for decryption", async () => {
const newValue = true;
const expectedValue = true;
stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice();
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId);
expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual(expectedValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
expect(result).toEqual(newValue);
});
});
describe("setShouldTrustDevice", () => {
it("sets the user trust device choice for decryption in the state service", async () => {
const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice");
it("sets the user trust device choice for decryption ", async () => {
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId);
const newValue = true;
await deviceTrustCryptoService.setShouldTrustDevice(newValue);
await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue);
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
expect(result).toEqual(newValue);
});
});
});
@ -98,11 +109,11 @@ describe("deviceTrustCryptoService", () => {
jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse);
jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue();
await deviceTrustCryptoService.trustDeviceIfRequired();
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1);
expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1);
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false);
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false);
});
it("should not trust device nor reset when getShouldTrustDevice returns false", async () => {
@ -112,7 +123,7 @@ describe("deviceTrustCryptoService", () => {
const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice");
const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice");
await deviceTrustCryptoService.trustDeviceIfRequired();
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(trustDeviceSpy).not.toHaveBeenCalled();
@ -126,53 +137,140 @@ describe("deviceTrustCryptoService", () => {
describe("getDeviceKey", () => {
let existingDeviceKey: DeviceKey;
let stateSvcGetDeviceKeySpy: jest.SpyInstance;
let existingDeviceKeyB64: { keyB64: string };
beforeEach(() => {
existingDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey");
existingDeviceKeyB64 = existingDeviceKey.toJSON();
});
it("returns null when there is not an existing device key", async () => {
stateSvcGetDeviceKeySpy.mockResolvedValue(null);
describe("Secure Storage not supported", () => {
it("returns null when there is not an existing device key", async () => {
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(deviceKey).toBeNull();
expect(secureStorageService.get).not.toHaveBeenCalled();
});
expect(deviceKey).toBeNull();
it("returns the device key when there is an existing device key", async () => {
await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId);
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
expect(secureStorageService.get).not.toHaveBeenCalled();
});
});
it("returns the device key when there is an existing device key", async () => {
stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey);
describe("Secure Storage supported", () => {
beforeEach(() => {
const supportsSecureStorage = true;
deviceTrustCryptoService = createDeviceTrustCryptoService(
mockUserId,
supportsSecureStorage,
);
});
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
it("returns null when there is not an existing device key for the passed in user id", async () => {
secureStorageService.get.mockResolvedValue(null);
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
// Act
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
// Assert
expect(deviceKey).toBeNull();
});
it("returns the device key when there is an existing device key for the passed in user id", async () => {
// Arrange
secureStorageService.get.mockResolvedValue(existingDeviceKeyB64);
// Act
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
// Assert
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
});
});
it("throws an error when no user id is passed in", async () => {
await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow(
"UserId is required. Cannot get device key.",
);
});
});
describe("setDeviceKey", () => {
it("sets the device key in the state service", async () => {
const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey");
describe("Secure Storage not supported", () => {
it("successfully sets the device key in state provider", async () => {
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
const deviceKey = new SymmetricCryptoKey(
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith(
DEVICE_KEY,
newDeviceKey.toJSON(),
mockUserId,
);
});
});
describe("Secure Storage supported", () => {
beforeEach(() => {
const supportsSecureStorage = true;
deviceTrustCryptoService = createDeviceTrustCryptoService(
mockUserId,
supportsSecureStorage,
);
});
it("successfully sets the device key in secure storage", async () => {
// Arrange
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
secureStorageService.get.mockResolvedValue(null);
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// Act
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
// Assert
expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2);
expect(secureStorageService.save).toHaveBeenCalledWith(
deviceKeySecureStorageKey,
newDeviceKey,
secureStorageOptions,
);
});
});
it("throws an error when a null user id is passed in", async () => {
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(deviceKey);
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey);
await expect(
(deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey),
).rejects.toThrow("UserId is required. Cannot set device key.");
});
});
@ -300,7 +398,7 @@ describe("deviceTrustCryptoService", () => {
});
it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => {
const response = await deviceTrustCryptoService.trustDevice();
const response = await deviceTrustCryptoService.trustDevice(mockUserId);
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
@ -331,7 +429,7 @@ describe("deviceTrustCryptoService", () => {
// setup the spy to return null
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
// check if the expected error is thrown
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
);
@ -341,7 +439,7 @@ describe("deviceTrustCryptoService", () => {
// setup the spy to return undefined
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
// check if the expected error is thrown
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
);
});
@ -381,7 +479,9 @@ describe("deviceTrustCryptoService", () => {
it(`throws an error if ${method} fails`, async () => {
const methodSpy = spy();
methodSpy.mockRejectedValue(new Error(errorText));
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText);
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
errorText,
);
});
test.each([null, undefined])(
@ -389,11 +489,17 @@ describe("deviceTrustCryptoService", () => {
async (invalidValue) => {
const methodSpy = spy();
methodSpy.mockResolvedValue(invalidValue);
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow();
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow();
},
);
},
);
it("throws an error when a null user id is passed in", async () => {
await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow(
"UserId is required. Cannot trust device.",
);
});
});
describe("decryptUserKeyWithDeviceKey", () => {
@ -422,19 +528,26 @@ describe("deviceTrustCryptoService", () => {
jest.clearAllMocks();
});
it("returns null when device key isn't provided and isn't in state", async () => {
const getDeviceKeySpy = jest
.spyOn(deviceTrustCryptoService, "getDeviceKey")
.mockResolvedValue(null);
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
null,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
),
).rejects.toThrow("UserId is required. Cannot decrypt user key with device key.");
});
it("returns null when device key isn't provided", async () => {
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
);
expect(result).toBeNull();
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
});
it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => {
@ -446,6 +559,7 @@ describe("deviceTrustCryptoService", () => {
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
@ -456,31 +570,6 @@ describe("deviceTrustCryptoService", () => {
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
});
it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => {
const getDeviceKeySpy = jest
.spyOn(deviceTrustCryptoService, "getDeviceKey")
.mockResolvedValue(mockDeviceKey);
const decryptToBytesSpy = jest
.spyOn(encryptService, "decryptToBytes")
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
const rsaDecryptSpy = jest
.spyOn(cryptoService, "rsaDecrypt")
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
// Call without providing a device key
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
);
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockUserKey);
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
});
it("returns null and removes device key when the decryption fails", async () => {
const decryptToBytesSpy = jest
.spyOn(encryptService, "decryptToBytes")
@ -488,6 +577,7 @@ describe("deviceTrustCryptoService", () => {
const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey");
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
@ -496,7 +586,7 @@ describe("deviceTrustCryptoService", () => {
expect(result).toBeNull();
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
expect(setDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(setDeviceKeySpy).toHaveBeenCalledWith(null);
expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null);
});
});
@ -514,19 +604,28 @@ describe("deviceTrustCryptoService", () => {
cryptoService.activeUserKey$ = of(fakeNewUserKey);
});
it("does an early exit when the current device is not a trusted device", async () => {
stateService.getDeviceKey.mockResolvedValue(null);
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""),
).rejects.toThrow("UserId is required. Cannot rotate device's trust.");
});
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "");
it("does an early exit when the current device is not a trusted device", async () => {
const deviceKeyState: FakeActiveUserState<DeviceKey> =
stateProvider.activeUser.getFake(DEVICE_KEY);
deviceKeyState.nextState(null);
await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, "");
expect(devicesApiService.updateTrust).not.toHaveBeenCalled();
});
describe("is on a trusted device", () => {
beforeEach(() => {
stateService.getDeviceKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey,
);
beforeEach(async () => {
const mockDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength),
) as DeviceKey;
await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId);
});
it("rotates current device keys and calls api service when the current device is trusted", async () => {
@ -592,7 +691,11 @@ describe("deviceTrustCryptoService", () => {
);
});
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash");
await deviceTrustCryptoService.rotateDevicesTrust(
mockUserId,
fakeNewUserKey,
"my_password_hash",
);
expect(devicesApiService.updateTrust).toHaveBeenCalledWith(
matches((updateTrustModel: UpdateDevicesTrustRequest) => {
@ -608,4 +711,32 @@ describe("deviceTrustCryptoService", () => {
});
});
});
// Helpers
function createDeviceTrustCryptoService(
mockUserId: UserId | null,
supportsSecureStorage: boolean,
) {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
return new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
cryptoService,
encryptService,
appIdService,
devicesApiService,
i18nService,
platformUtilsService,
stateProvider,
secureStorageService,
userDecryptionOptionsService,
);
}
});

View File

@ -1,4 +1,5 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
@ -104,6 +105,61 @@ describe("TokenService", () => {
const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`;
const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`;
describe("hasAccessToken$", () => {
it("returns true when an access token exists in memory", async () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
// Assert
expect(result).toEqual(true);
});
it("returns true when an access token exists in disk", async () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
.stateSubject.next([userIdFromAccessToken, undefined]);
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
// Assert
expect(result).toEqual(true);
});
it("returns true when an access token exists in secure storage", async () => {
// Arrange
singleUserStateProvider
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
// Assert
expect(result).toEqual(true);
});
it("should return false if no access token exists in memory, disk, or secure storage", async () => {
// Act
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
// Assert
expect(result).toEqual(false);
});
});
describe("setAccessToken", () => {
it("should throw an error if the access token is null", async () => {
// Act

View File

@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { Opaque } from "type-fest";
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
@ -135,6 +135,15 @@ export class TokenService implements TokenServiceAbstraction {
this.initializeState();
}
hasAccessToken$(userId: UserId): Observable<boolean> {
// FIXME Once once vault timeout action is observable, we can use it to determine storage location
// and avoid the need to check both disk and memory.
return combineLatest([
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$,
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$,
]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
}
// pivoting to an approach where we create a symmetric key we store in secure storage
// which is used to protect the data before persisting to disk.
// We will also use the same symmetric key to decrypt the data when reading from disk.

View File

@ -13,6 +13,14 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
export abstract class CryptoService {
abstract activeUserKey$: Observable<UserKey>;
/**
* Returns the an observable key for the given user id.
*
* @note this observable represents only user keys stored in memory. A null value does not indicate that we cannot load a user key from storage.
* @param userId The desired user
*/
abstract getInMemoryUserKeyFor$(userId: UserId): Observable<UserKey>;
/**
* Sets the provided user key and stores
* any other necessary versions (such as auto, biometrics,

View File

@ -10,7 +10,7 @@ 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 { UserId } from "../../types/guid";
import { DeviceKey, MasterKey } from "../../types/key";
import { MasterKey } from "../../types/key";
import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
@ -50,8 +50,6 @@ export abstract class StateService<T extends Account = Account> {
getAddEditCipherInfo: (options?: StorageOptions) => Promise<AddEditCipherInfo>;
setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
/**
* Gets the user's master key
*/
@ -161,19 +159,13 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService
*/
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
getDisableGa: (options?: StorageOptions) => Promise<boolean>;
setDisableGa: (value: boolean, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getDeviceKey: (options?: StorageOptions) => Promise<DeviceKey | null>;
setDeviceKey: (value: DeviceKey | null, options?: StorageOptions) => Promise<void>;
getAdminAuthRequest: (options?: StorageOptions) => Promise<AdminAuthRequestStorable | null>;
setAdminAuthRequest: (
adminAuthRequest: AdminAuthRequestStorable,
options?: StorageOptions,
) => Promise<void>;
getShouldTrustDevice: (options?: StorageOptions) => Promise<boolean | null>;
setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
@ -220,8 +212,6 @@ export abstract class StateService<T extends Account = Account> {
value: ForceSetPasswordReason,
options?: StorageOptions,
) => Promise<void>;
getInstalledVersion: (options?: StorageOptions) => Promise<string>;
setInstalledVersion: (value: string, options?: StorageOptions) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
getKdfConfig: (options?: StorageOptions) => Promise<KdfConfig>;
setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise<void>;

View File

@ -1,6 +1,4 @@
import { makeStaticByteArray } from "../../../../spec";
import { CsprngArray } from "../../../types/csprng";
import { DeviceKey } from "../../../types/key";
import { Utils } from "../../misc/utils";
import { AccountKeys, EncryptionPair } from "./account";
@ -24,23 +22,6 @@ describe("AccountKeys", () => {
const json = JSON.stringify(keys);
expect(json).toContain('"publicKey":"hello"');
});
// As the accountKeys.toJSON doesn't really serialize the device key
// this method just checks the persistence of the deviceKey
it("should persist deviceKey", () => {
// Arrange
const accountKeys = new AccountKeys();
const deviceKeyBytesLength = 64;
accountKeys.deviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray,
) as DeviceKey;
// Act
const serializedKeys = accountKeys.toJSON();
// Assert
expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey);
});
});
describe("fromJSON", () => {
@ -64,24 +45,5 @@ describe("AccountKeys", () => {
} as any);
expect(spy).toHaveBeenCalled();
});
it("should deserialize deviceKey", () => {
// Arrange
const expectedKeyB64 =
"ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg==";
const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
// Act
const accountKeys = AccountKeys.fromJSON({
deviceKey: {
keyB64: expectedKeyB64,
},
} as any);
// Assert
expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled();
expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64);
});
});
});

View File

@ -65,13 +65,6 @@ export class DataEncryptionPair<TEncrypted, TDecrypted> {
decrypted?: TDecrypted[];
}
// This is a temporary structure to handle migrated `DataEncryptionPair` to
// avoid needing a data migration at this stage. It should be replaced with
// proper data migrations when `DataEncryptionPair` is deprecated.
export class TemporaryDataEncryption<TEncrypted> {
encrypted?: { [id: string]: TEncrypted };
}
export class AccountData {
ciphers?: DataEncryptionPair<CipherData, CipherView> = new DataEncryptionPair<
CipherData,
@ -102,7 +95,6 @@ export class AccountData {
export class AccountKeys {
masterKey?: MasterKey;
masterKeyEncryptedUserKey?: string;
deviceKey?: ReturnType<SymmetricCryptoKey["toJSON"]>;
publicKey?: Uint8Array;
/** @deprecated July 2023, left for migration purposes*/
@ -132,7 +124,6 @@ export class AccountKeys {
}
return Object.assign(new AccountKeys(), obj, {
masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey),
deviceKey: obj?.deviceKey,
cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey),
cryptoSymmetricKey: EncryptionPair.fromJSON(
obj?.cryptoSymmetricKey,
@ -182,8 +173,6 @@ export class AccountProfile {
export class AccountSettings {
defaultUriMatch?: UriMatchStrategySetting;
disableGa?: boolean;
enableBiometric?: boolean;
minimizeOnCopyToClipboard?: boolean;
passwordGenerationOptions?: PasswordGeneratorOptions;
usernameGenerationOptions?: UsernameGeneratorOptions;
@ -194,8 +183,6 @@ export class AccountSettings {
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
approveLoginRequests?: boolean;
avatarColor?: string;
trustDeviceChoiceForDecryption?: boolean;
/** @deprecated July 2023, left for migration purposes*/
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();

View File

@ -1,15 +1,7 @@
import { ThemeType } from "../../enums";
export class GlobalState {
installedVersion?: string;
organizationInvitation?: any;
theme?: ThemeType = ThemeType.System;
twoFactorToken?: string;
biometricFingerprintValidated?: boolean;
vaultTimeout?: number;
vaultTimeoutAction?: string;
loginRedirect?: any;
mainWindowSize?: number;
enableBrowserIntegration?: boolean;
enableBrowserIntegrationFingerprint?: boolean;
deepLinkRedirectUrl?: string;

View File

@ -160,6 +160,10 @@ export class CryptoService implements CryptoServiceAbstraction {
await this.setUserKey(key);
}
getInMemoryUserKeyFor$(userId: UserId): Observable<UserKey> {
return this.stateProvider.getUserState$(USER_KEY, userId);
}
async getUserKey(userId?: UserId): Promise<UserKey> {
let userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
if (userKey) {

View File

@ -14,7 +14,7 @@ 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 { UserId } from "../../types/guid";
import { DeviceKey, MasterKey } from "../../types/key";
import { MasterKey } from "../../types/key";
import { CipherData } from "../../vault/models/data/cipher.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
@ -275,24 +275,6 @@ export class StateService<
);
}
async getBiometricFingerprintValidated(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.biometricFingerprintValidated ?? false
);
}
async setBiometricFingerprintValidated(value: boolean, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.biometricFingerprintValidated = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
/**
* @deprecated Do not save the Master Key. Use the User Symmetric Key instead
*/
@ -650,24 +632,6 @@ export class StateService<
);
}
async getDisableGa(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.disableGa ?? false
);
}
async setDisableGa(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.settings.disableGa = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
@ -686,39 +650,6 @@ export class StateService<
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
}
async getDeviceKey(options?: StorageOptions): Promise<DeviceKey | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return null;
}
const account = await this.getAccount(options);
const existingDeviceKey = account?.keys?.deviceKey;
// Must manually instantiate the SymmetricCryptoKey class from the JSON object
if (existingDeviceKey != null) {
return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey;
} else {
return null;
}
}
async setDeviceKey(value: DeviceKey | null, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return;
}
const account = await this.getAccount(options);
account.keys.deviceKey = value?.toJSON() ?? null;
await this.saveAccount(account, options);
}
async getAdminAuthRequest(options?: StorageOptions): Promise<AdminAuthRequestStorable | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
@ -750,31 +681,6 @@ export class StateService<
await this.saveAccount(account, options);
}
async getShouldTrustDevice(options?: StorageOptions): Promise<boolean | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return null;
}
const account = await this.getAccount(options);
return account?.settings?.trustDeviceChoiceForDecryption ?? null;
}
async setShouldTrustDevice(value: boolean, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return;
}
const account = await this.getAccount(options);
account.settings.trustDeviceChoiceForDecryption = value;
await this.saveAccount(account, options);
}
async getEmail(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
@ -982,23 +888,6 @@ export class StateService<
);
}
async getInstalledVersion(options?: StorageOptions): Promise<string> {
return (
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.installedVersion;
}
async setInstalledVersion(value: string, options?: StorageOptions): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.installedVersion = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
return (
(await this.tokenService.getAccessToken(options?.userId as UserId)) != null &&
@ -1686,7 +1575,6 @@ export class StateService<
protected resetAccount(account: TAccount) {
const persistentAccountInformation = {
settings: account.settings,
keys: { deviceKey: account.keys.deviceKey },
adminAuthRequest: account.adminAuthRequest,
};
return Object.assign(this.createAccount(), persistentAccountInformation);

View File

@ -48,6 +48,9 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {
web: "disk-local",
});
export const TOKEN_MEMORY = new StateDefinition("token", "memory");
export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", {
web: "disk-local",
});
export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk");
// Autofill

View File

@ -6,24 +6,25 @@ import { StateUpdateOptions } from "./state-update-options";
export type CombinedState<T> = readonly [userId: UserId, state: T];
/**
* A helper object for interacting with state that is scoped to a specific user.
*/
/** A helper object for interacting with state that is scoped to a specific user. */
export interface UserState<T> {
/**
* Emits a stream of data.
*/
readonly state$: Observable<T>;
/** Emits a stream of data. Emits null if the user does not have specified state. */
readonly state$: Observable<T | null>;
/**
* Emits a stream of data alongside the user id the data corresponds to.
*/
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
readonly combinedState$: Observable<CombinedState<T>>;
}
export const activeMarker: unique symbol = Symbol("active");
export interface ActiveUserState<T> extends UserState<T> {
readonly [activeMarker]: true;
/**
* Emits a stream of data. Emits null if the user does not have specified state.
* Note: Will not emit if there is no active user.
*/
readonly state$: Observable<T | null>;
/**
* Updates backing stores for the active user.
* @param configureState function that takes the current state and returns the new state

View File

@ -48,6 +48,8 @@ import { AccountServerConfigMigrator } from "./migrations/49-move-account-server
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider";
import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers";
import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version";
import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers";
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";
@ -55,8 +57,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 51;
export const CURRENT_VERSION = 53;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@ -109,7 +110,9 @@ export function createMigrationBuilder() {
.with(MoveDdgToStateProviderMigrator, 47, 48)
.with(AccountServerConfigMigrator, 48, 49)
.with(KeyConnectorMigrator, 49, 50)
.with(RememberedEmailMigrator, 50, CURRENT_VERSION);
.with(RememberedEmailMigrator, 50, 51)
.with(DeleteInstalledVersion, 51, 52)
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,35 @@
import { runMigrator } from "../migration-helper.spec";
import { DeleteInstalledVersion } from "./52-delete-installed-version";
describe("DeleteInstalledVersion", () => {
const sut = new DeleteInstalledVersion(51, 52);
describe("migrate", () => {
it("can delete data if there", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {
installedVersion: "2024.1.1",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
});
});
it("will run if installed version is not there", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
});
});
});
});

View File

@ -0,0 +1,19 @@
import { MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedGlobal = {
installedVersion?: string;
};
export class DeleteInstalledVersion extends Migrator<51, 52> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobal = await helper.get<ExpectedGlobal>("global");
if (legacyGlobal?.installedVersion != null) {
delete legacyGlobal.installedVersion;
await helper.set("global", legacyGlobal);
}
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@ -0,0 +1,171 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
DEVICE_KEY,
DeviceTrustCryptoServiceStateProviderMigrator,
SHOULD_TRUST_DEVICE,
} from "./53-migrate-device-trust-crypto-svc-to-state-providers";
// Represents data in state service pre-migration
function preMigrationJson() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
keys: {
deviceKey: {
keyB64: "user1_deviceKey",
},
otherStuff: "overStuff2",
},
settings: {
trustDeviceChoiceForDecryption: true,
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
},
user2: {
keys: {
// no device key
otherStuff: "otherStuff5",
},
settings: {
// no trust device choice
otherStuff: "overStuff6",
},
otherStuff: "otherStuff7",
},
};
}
function rollbackJSON() {
return {
// use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for each user
// User1 migrated data
user_user1_deviceTrust_deviceKey: {
keyB64: "user1_deviceKey",
},
user_user1_deviceTrust_shouldTrustDevice: true,
// User2 does not have migrated data
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
keys: {
otherStuff: "overStuff2",
},
settings: {
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
},
user2: {
keys: {
otherStuff: "otherStuff5",
},
settings: {
otherStuff: "overStuff6",
},
otherStuff: "otherStuff6",
},
};
}
describe("DeviceTrustCryptoServiceStateProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: DeviceTrustCryptoServiceStateProviderMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 52);
sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53);
});
// it should remove deviceKey and trustDeviceChoiceForDecryption from all accounts
it("should remove deviceKey and trustDeviceChoiceForDecryption from all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
keys: {
otherStuff: "overStuff2",
},
settings: {
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
});
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
it("should migrate deviceKey and trustDeviceChoiceForDecryption to state providers for accounts that have the data", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, {
keyB64: "user1_deviceKey",
});
expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, true);
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", DEVICE_KEY, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", DEVICE_KEY, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, any());
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 53);
sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53);
});
it("should null out newly migrated entries in state provider framework", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, null);
});
it("should add back deviceKey and trustDeviceChoiceForDecryption to all accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
keys: {
deviceKey: {
keyB64: "user1_deviceKey",
},
otherStuff: "overStuff2",
},
settings: {
trustDeviceChoiceForDecryption: true,
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
});
});
it("should not add data back if data wasn't migrated or acct doesn't exist", async () => {
await sut.rollback(helper);
// no data to add back for user2 (acct exists but no migrated data) and user3 (no acct)
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
});
});

View File

@ -0,0 +1,95 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// Types to represent data as it is stored in JSON
type DeviceKeyJsonType = {
keyB64: string;
};
type ExpectedAccountType = {
keys?: {
deviceKey?: DeviceKeyJsonType;
};
settings?: {
trustDeviceChoiceForDecryption?: boolean;
};
};
export const DEVICE_KEY: KeyDefinitionLike = {
key: "deviceKey", // matches KeyDefinition.key in DeviceTrustCryptoService
stateDefinition: {
name: "deviceTrust", // matches StateDefinition.name in StateDefinitions
},
};
export const SHOULD_TRUST_DEVICE: KeyDefinitionLike = {
key: "shouldTrustDevice",
stateDefinition: {
name: "deviceTrust",
},
};
export class DeviceTrustCryptoServiceStateProviderMigrator extends Migrator<52, 53> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Migrate deviceKey
const existingDeviceKey = account?.keys?.deviceKey;
if (existingDeviceKey != null) {
// Only migrate data that exists
await helper.setToUser(userId, DEVICE_KEY, existingDeviceKey);
delete account.keys.deviceKey;
updatedAccount = true;
}
// Migrate shouldTrustDevice
const existingShouldTrustDevice = account?.settings?.trustDeviceChoiceForDecryption;
if (existingShouldTrustDevice != null) {
await helper.setToUser(userId, SHOULD_TRUST_DEVICE, existingShouldTrustDevice);
delete account.settings.trustDeviceChoiceForDecryption;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account
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> {
// Rollback deviceKey
const migratedDeviceKey: DeviceKeyJsonType = await helper.getFromUser(userId, DEVICE_KEY);
if (account?.keys && migratedDeviceKey != null) {
account.keys.deviceKey = migratedDeviceKey;
await helper.set(userId, account);
}
await helper.setToUser(userId, DEVICE_KEY, null);
// Rollback shouldTrustDevice
const migratedShouldTrustDevice = await helper.getFromUser<boolean>(
userId,
SHOULD_TRUST_DEVICE,
);
if (account?.settings && migratedShouldTrustDevice != null) {
account.settings.trustDeviceChoiceForDecryption = migratedShouldTrustDevice;
await helper.set(userId, account);
}
await helper.setToUser(userId, SHOULD_TRUST_DEVICE, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

189
package-lock.json generated
View File

@ -67,7 +67,7 @@
"qrious": "4.0.2",
"rxjs": "7.8.1",
"tabbable": "6.2.0",
"tldts": "6.1.13",
"tldts": "6.1.16",
"utf-8-validate": "6.0.3",
"zone.js": "0.13.3",
"zxcvbn": "4.4.2"
@ -117,8 +117,8 @@
"@types/react": "16.14.57",
"@types/retry": "0.12.5",
"@types/zxcvbn": "4.4.4",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18",
"base64-loader": "1.0.0",
@ -161,7 +161,7 @@
"postcss": "8.4.35",
"postcss-loader": "8.1.1",
"prettier": "3.2.2",
"prettier-plugin-tailwindcss": "0.5.12",
"prettier-plugin-tailwindcss": "0.5.13",
"process": "0.11.10",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -224,7 +224,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "6.1.13",
"tldts": "6.1.16",
"zxcvbn": "4.4.2"
},
"bin": {
@ -11982,16 +11982,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz",
"integrity": "sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/type-utils": "7.4.0",
"@typescript-eslint/utils": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@ -12000,15 +12000,15 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
"eslint": "^7.0.0 || ^8.0.0"
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@ -12017,16 +12017,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz",
"integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12034,25 +12034,25 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz",
"integrity": "sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/typescript-estree": "7.4.0",
"@typescript-eslint/utils": "7.4.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@ -12061,12 +12061,12 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz",
"integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12074,13 +12074,13 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz",
"integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@ -12089,7 +12089,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12102,41 +12102,41 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.4.0.tgz",
"integrity": "sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/typescript-estree": "7.4.0",
"semver": "^7.5.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz",
"integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/types": "7.4.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12235,26 +12235,26 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.4.0.tgz",
"integrity": "sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"@typescript-eslint/scope-manager": "7.4.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/typescript-estree": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@ -12263,16 +12263,16 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz",
"integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12280,12 +12280,12 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz",
"integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12293,13 +12293,13 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz",
"integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"@typescript-eslint/types": "7.4.0",
"@typescript-eslint/visitor-keys": "7.4.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@ -12308,7 +12308,7 @@
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -12321,16 +12321,16 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz",
"integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/types": "7.4.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@ -31723,9 +31723,9 @@
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.5.12",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.12.tgz",
"integrity": "sha512-o74kiDBVE73oHW+pdkFSluHBL3cYEvru5YgEqNkBMFF7Cjv+w1vI565lTlfoJT4VLWDe0FMtZ7FkE/7a4pMXSQ==",
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.13.tgz",
"integrity": "sha512-2tPWHCFNC+WRjAC4SIWQNSOdcL1NNkydXim8w7TDqlZi+/ulZYz2OouAI6qMtkggnPt7lGamboj6LcTMwcCvoQ==",
"dev": true,
"engines": {
"node": ">=14.21.3"
@ -31735,6 +31735,7 @@
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig-melody": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
@ -31760,6 +31761,9 @@
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig-melody": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
@ -31789,9 +31793,6 @@
},
"prettier-plugin-svelte": {
"optional": true
},
"prettier-plugin-twig-melody": {
"optional": true
}
}
},
@ -36528,20 +36529,20 @@
"dev": true
},
"node_modules/tldts": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.13.tgz",
"integrity": "sha512-+GxHFKVHvUTg2ieNPTx3b/NpZbgJSTZEDdI4cJzTjVYDuxijeHi1tt7CHHsMjLqyc+T50VVgWs3LIb2LrXOzxw==",
"version": "6.1.16",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.16.tgz",
"integrity": "sha512-X6VrQzW4RymhI1kBRvrWzYlRLXTftZpi7/s/9ZlDILA04yM2lNX7mBvkzDib9L4uSymHt8mBbeaielZMdsAkfQ==",
"dependencies": {
"tldts-core": "^6.1.13"
"tldts-core": "^6.1.16"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.13.tgz",
"integrity": "sha512-M1XP4D13YtXARKroULnLsKKuI1NCRAbJmUGGoXqWinajIDOhTeJf/trYUyBoLVx1/Nx1KBKxCrlW57ZW9cMHAA=="
"version": "6.1.16",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.16.tgz",
"integrity": "sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw=="
},
"node_modules/tmp": {
"version": "0.0.33",

View File

@ -78,8 +78,8 @@
"@types/react": "16.14.57",
"@types/retry": "0.12.5",
"@types/zxcvbn": "4.4.4",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@webcomponents/custom-elements": "1.6.0",
"autoprefixer": "10.4.18",
"base64-loader": "1.0.0",
@ -122,7 +122,7 @@
"postcss": "8.4.35",
"postcss-loader": "8.1.1",
"prettier": "3.2.2",
"prettier-plugin-tailwindcss": "0.5.12",
"prettier-plugin-tailwindcss": "0.5.13",
"process": "0.11.10",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -200,7 +200,7 @@
"qrious": "4.0.2",
"rxjs": "7.8.1",
"tabbable": "6.2.0",
"tldts": "6.1.13",
"tldts": "6.1.16",
"utf-8-validate": "6.0.3",
"zone.js": "0.13.3",
"zxcvbn": "4.4.2"