1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-18026] Implement forced, automatic KDF upgrades (#15937)

* Implement automatic kdf upgrades

* Fix kdf config not being updated

* Update legacy kdf state on master password unlock sync

* Fix cli build

* Fix

* Deduplicate prompts

* Fix dismiss time

* Fix default kdf setting

* Fix build

* Undo changes

* Fix test

* Fix prettier

* Fix test

* Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Update libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Only sync when there is at least one migration

* Relative imports

* Add tech debt comment

* Resolve inconsistent prefix

* Clean up

* Update docs

* Use default PBKDF2 iteratinos instead of custom threshold

* Undo type check

* Fix build

* Add comment

* Cleanup

* Cleanup

* Address component feedback

* Use isnullorwhitespace

* Fix tests

* Allow migration only on vault

* Fix tests

* Run prettier

* Fix tests

* Prevent await race condition

* Fix min and default values in kdf migration

* Run sync only when a migration was run

* Update libs/common/src/key-management/encrypted-migrator/default-encrypted-migrator.ts

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>

* Fix link not being blue

* Fix later button on browser

---------

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-12-03 19:04:18 +01:00
committed by GitHub
parent 6ae096485a
commit 6e2203d6d4
48 changed files with 1471 additions and 31 deletions

View File

@@ -1406,6 +1406,27 @@
"learnMore": { "learnMore": {
"message": "Learn more" "message": "Learn more"
}, },
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"later": {
"message": "Later"
},
"authenticatorKeyTotp": { "authenticatorKeyTotp": {
"message": "Authenticator key (TOTP)" "message": "Authenticator key (TOTP)"
}, },

View File

@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
import { ClientType } from "@bitwarden/common/enums"; import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -81,6 +82,7 @@ export class LoginCommand {
protected ssoUrlService: SsoUrlService, protected ssoUrlService: SsoUrlService,
protected i18nService: I18nService, protected i18nService: I18nService,
protected masterPasswordService: MasterPasswordServiceAbstraction, protected masterPasswordService: MasterPasswordServiceAbstraction,
protected encryptedMigrator: EncryptedMigrator,
) {} ) {}
async run(email: string, password: string, options: OptionValues) { async run(email: string, password: string, options: OptionValues) {
@@ -367,6 +369,8 @@ export class LoginCommand {
} }
} }
await this.encryptedMigrator.runMigrations(response.userId, password);
return await this.handleSuccessResponse(response); return await this.handleSuccessResponse(response);
} catch (e) { } catch (e) {
if ( if (

View File

@@ -182,6 +182,7 @@ export abstract class BaseProgram {
this.serviceContainer.organizationApiService, this.serviceContainer.organizationApiService,
this.serviceContainer.logout, this.serviceContainer.logout,
this.serviceContainer.i18nService, this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService, this.serviceContainer.configService,
); );

View File

@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
const organizationApiService = mock<OrganizationApiServiceAbstraction>(); const organizationApiService = mock<OrganizationApiServiceAbstraction>();
const logout = jest.fn(); const logout = jest.fn();
const i18nService = mock<I18nService>(); const i18nService = mock<I18nService>();
const encryptedMigrator = mock<EncryptedMigrator>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>(); const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const configService = mock<ConfigService>(); const configService = mock<ConfigService>();
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
organizationApiService, organizationApiService,
logout, logout,
i18nService, i18nService,
encryptedMigrator,
masterPasswordUnlockService, masterPasswordUnlockService,
configService, configService,
); );

View File

@@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -38,6 +39,7 @@ export class UnlockCommand {
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private logout: () => Promise<void>, private logout: () => Promise<void>,
private i18nService: I18nService, private i18nService: I18nService,
private encryptedMigrator: EncryptedMigrator,
private masterPasswordUnlockService: MasterPasswordUnlockService, private masterPasswordUnlockService: MasterPasswordUnlockService,
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
@@ -116,6 +118,8 @@ export class UnlockCommand {
} }
} }
await this.encryptedMigrator.runMigrations(userId, password);
return this.successResponse(); return this.successResponse();
} }

View File

@@ -176,6 +176,7 @@ export class OssServeConfigurator {
this.serviceContainer.organizationApiService, this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(), async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService, this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService, this.serviceContainer.configService,
); );

View File

@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
this.serviceContainer.ssoUrlService, this.serviceContainer.ssoUrlService,
this.serviceContainer.i18nService, this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordService, this.serviceContainer.masterPasswordService,
this.serviceContainer.encryptedMigrator,
); );
const response = await command.run(email, password, options); const response = await command.run(email, password, options);
this.processResponse(response, true); this.processResponse(response, true);
@@ -311,6 +312,7 @@ export class Program extends BaseProgram {
this.serviceContainer.organizationApiService, this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(), async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService, this.serviceContainer.i18nService,
this.serviceContainer.encryptedMigrator,
this.serviceContainer.masterPasswordUnlockService, this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService, this.serviceContainer.configService,
); );

View File

@@ -76,6 +76,10 @@ import {
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -324,6 +328,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService; cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService; restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService;
encryptedMigrator: EncryptedMigrator;
securityStateService: SecurityStateService; securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService; masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService; cipherArchiveService: CipherArchiveService;
@@ -975,6 +980,16 @@ export class ServiceContainer {
); );
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService); this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
this.encryptedMigrator = new DefaultEncryptedMigrator(
this.kdfConfigService,
changeKdfService,
this.logService,
this.configService,
this.masterPasswordService,
this.syncService,
);
} }
async logout() { async logout() {

View File

@@ -1093,6 +1093,24 @@
"learnMore": { "learnMore": {
"message": "Learn more" "message": "Learn more"
}, },
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"featureUnavailable": { "featureUnavailable": {
"message": "Feature unavailable" "message": "Feature unavailable"
}, },

View File

@@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit {
message: this.i18nService.t("twoStepRecoverDisabled"), message: this.i18nService.t("twoStepRecoverDisabled"),
}); });
await this.loginSuccessHandlerService.run(authResult.userId); await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
await this.router.navigate(["/settings/security/two-factor"]); await this.router.navigate(["/settings/security/two-factor"]);
} catch (error: unknown) { } catch (error: unknown) {

View File

@@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs"; import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";

View File

@@ -4621,6 +4621,24 @@
"learnMore": { "learnMore": {
"message": "Learn more" "message": "Learn more"
}, },
"migrationsFailed": {
"message": "An error occurred updating the encryption settings."
},
"updateEncryptionSettingsTitle": {
"message": "Update your encryption settings"
},
"updateEncryptionSettingsDesc": {
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
},
"confirmIdentityToContinue": {
"message": "Confirm your identity to continue"
},
"enterYourMasterPassword": {
"message": "Enter your master password"
},
"updateSettings": {
"message": "Update settings"
},
"deleteRecoverDesc": { "deleteRecoverDesc": {
"message": "Enter your email address below to recover and delete your account." "message": "Enter your email address below to recover and delete your account."
}, },

View File

@@ -120,7 +120,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption. // Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId)); const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
if (userKey) { if (userKey) {
await this.loginSuccessHandlerService.run(authResult.userId); await this.loginSuccessHandlerService.run(authResult.userId, null);
} }
await this.router.navigate([this.successRoute]); await this.router.navigate([this.successRoute]);

View File

@@ -0,0 +1,9 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class EncryptedMigrationsSchedulerService {
/**
* Runs migrations for a user if needed, handling both interactive and non-interactive cases
* @param userId The user ID to run migrations for
*/
abstract runMigrationsIfNeeded(userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,270 @@
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { FakeAccountService } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import {
DefaultEncryptedMigrationsSchedulerService,
ENCRYPTED_MIGRATION_DISMISSED,
} from "./encrypted-migrations-scheduler.service";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: {
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
};
describe("DefaultEncryptedMigrationsSchedulerService", () => {
let service: DefaultEncryptedMigrationsSchedulerService;
const mockAccountService = new FakeAccountService(accounts);
const mockAuthService = mock<AuthService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockStateProvider = mock<StateProvider>();
const mockSyncService = mock<SyncService>();
const mockDialogService = mock<DialogService>();
const mockToastService = mock<ToastService>();
const mockI18nService = mock<I18nService>();
const mockLogService = mock<LogService>();
const mockRouter = mock<Router>();
const mockUserId = "test-user-id" as UserId;
const mockMasterPassword = "test-master-password";
const createMockUserState = <T>(value: T): jest.Mocked<SingleUserState<T>> =>
({
state$: of(value),
userId: mockUserId,
update: jest.fn(),
combinedState$: of([mockUserId, value]),
}) as any;
beforeEach(() => {
const mockDialogRef = {
closed: of(mockMasterPassword),
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
mockI18nService.t.mockReturnValue("translated_migrationsFailed");
(mockRouter as any)["events"] = of({ url: "/vault" }) as any;
service = new DefaultEncryptedMigrationsSchedulerService(
mockSyncService,
mockAccountService,
mockStateProvider,
mockEncryptedMigrator,
mockAuthService,
mockLogService,
mockDialogService,
mockToastService,
mockI18nService,
mockRouter,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("runMigrationsIfNeeded", () => {
it("should return early if user is not unlocked", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).not.toHaveBeenCalled();
expect(mockLogService.info).not.toHaveBeenCalled();
});
it("should log and return when no migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("noMigrationNeeded");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] No migrations needed for user ${mockUserId}`,
);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
});
it("should run migrations without interaction when master password is not required", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
});
it("should run migrations with interaction when migration is needed", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.needsMigrations).toHaveBeenCalledWith(mockUserId);
expect(mockLogService.info).toHaveBeenCalledWith(
`[EncryptedMigrationsScheduler] User ${mockUserId} needs migrations with master password`,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("runMigrationsWithoutInteraction", () => {
it("should run migrations without master password", async () => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).not.toHaveBeenCalled();
});
it("should handle errors during migration without interaction", async () => {
const mockError = new Error("Migration failed");
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigration");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(mockUserId, null);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
mockError,
);
});
});
describe("runMigrationsWithInteraction", () => {
beforeEach(() => {
mockAuthService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
mockEncryptedMigrator.needsMigrations.mockResolvedValue("needsMigrationWithMasterPassword");
});
it("should skip if migration was dismissed recently", async () => {
const recentDismissDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
const mockUserState = createMockUserState(recentDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(mockLogService.info).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
expect(PromptMigrationPasswordComponent.open).not.toHaveBeenCalled();
});
it("should prompt for migration if dismissed date is older than 24 hours", async () => {
const oldDismissDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const mockUserState = createMockUserState(oldDismissDate);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(mockStateProvider.getUser).toHaveBeenCalledWith(
mockUserId,
ENCRYPTED_MIGRATION_DISMISSED,
);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should prompt for migration if no dismiss date exists", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
it("should set dismiss date when empty password is provided", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockDialogRef = {
closed: of(""), // Empty password
};
jest.spyOn(PromptMigrationPasswordComponent, "open").mockReturnValue(mockDialogRef as any);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).not.toHaveBeenCalled();
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
ENCRYPTED_MIGRATION_DISMISSED,
expect.any(Date),
mockUserId,
);
});
it("should handle errors during migration prompt and show toast", async () => {
const mockUserState = createMockUserState(null);
mockStateProvider.getUser.mockReturnValue(mockUserState);
const mockError = new Error("Migration failed");
mockEncryptedMigrator.runMigrations.mockRejectedValue(mockError);
await service.runMigrationsIfNeeded(mockUserId);
expect(PromptMigrationPasswordComponent.open).toHaveBeenCalledWith(mockDialogService);
expect(mockEncryptedMigrator.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
expect(mockLogService.error).toHaveBeenCalledWith(
"[EncryptedMigrationsScheduler] Error during migration prompt",
mockError,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "translated_migrationsFailed",
});
});
});
});

View File

@@ -0,0 +1,188 @@
import { NavigationEnd, Router } from "@angular/router";
import {
combineLatest,
switchMap,
of,
firstValueFrom,
filter,
concatMap,
Observable,
map,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
UserKeyDefinition,
ENCRYPTED_MIGRATION_DISK,
StateProvider,
} from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { EncryptedMigrationsSchedulerService } from "./encrypted-migrations-scheduler.service.abstraction";
import { PromptMigrationPasswordComponent } from "./prompt-migration-password.component";
export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
ENCRYPTED_MIGRATION_DISK,
"encryptedMigrationDismissed",
{
deserializer: (obj: string) => (obj != null ? new Date(obj) : null),
clearOn: [],
},
);
const DISMISS_TIME_HOURS = 24;
const VAULT_ROUTE = "/vault";
/**
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
*/
export class DefaultEncryptedMigrationsSchedulerService
implements EncryptedMigrationsSchedulerService
{
isMigrating = false;
url$: Observable<string>;
constructor(
private syncService: SyncService,
private accountService: AccountService,
private stateProvider: StateProvider,
private encryptedMigrator: EncryptedMigrator,
private authService: AuthService,
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
private router: Router,
) {
this.url$ = this.router.events.pipe(
filter((event: any) => event instanceof NavigationEnd),
map((event: NavigationEnd) => event.url),
);
// For all accounts, if the auth status changes to unlocked or a sync happens, prompt for migration
this.accountService.accounts$
.pipe(
switchMap((accounts) => {
const userIds = Object.keys(accounts) as UserId[];
if (userIds.length === 0) {
return of([]);
}
return combineLatest(
userIds.map((userId) =>
combineLatest([
this.authService.authStatusFor$(userId),
this.syncService.lastSync$(userId).pipe(filter((lastSync) => lastSync != null)),
this.url$,
]).pipe(
filter(
([authStatus, _date, url]) =>
authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE,
),
concatMap(() => this.runMigrationsIfNeeded(userId)),
),
),
);
}),
)
.subscribe();
}
async runMigrationsIfNeeded(userId: UserId): Promise<void> {
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus !== AuthenticationStatus.Unlocked) {
return;
}
if (this.isMigrating || this.encryptedMigrator.isRunningMigrations()) {
this.logService.info(
`[EncryptedMigrationsScheduler] Skipping migration check for user ${userId} because migrations are already in progress`,
);
return;
}
this.isMigrating = true;
switch (await this.encryptedMigrator.needsMigrations(userId)) {
case "noMigrationNeeded":
this.logService.info(
`[EncryptedMigrationsScheduler] No migrations needed for user ${userId}`,
);
break;
case "needsMigrationWithMasterPassword":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can run migrations with the master password
await this.runMigrationsWithInteraction(userId);
break;
case "needsMigration":
this.logService.info(
`[EncryptedMigrationsScheduler] User ${userId} needs migrations with master password`,
);
// If the user is unlocked, we can prompt for the master password
await this.runMigrationsWithoutInteraction(userId);
break;
}
this.isMigrating = false;
}
private async runMigrationsWithoutInteraction(userId: UserId): Promise<void> {
try {
await this.encryptedMigrator.runMigrations(userId, null);
} catch (error) {
this.logService.error(
"[EncryptedMigrationsScheduler] Error during migration without interaction",
error,
);
}
}
private async runMigrationsWithInteraction(userId: UserId): Promise<void> {
// A dialog can be dismissed for a certain amount of time
const dismissedDate = await firstValueFrom(
this.stateProvider.getUser(userId, ENCRYPTED_MIGRATION_DISMISSED).state$,
);
if (dismissedDate != null) {
const now = new Date();
const timeDiff = now.getTime() - (dismissedDate as Date).getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60);
if (hoursDiff < DISMISS_TIME_HOURS) {
this.logService.info(
"[EncryptedMigrationsScheduler] Migration prompt dismissed recently, skipping for now.",
);
return;
}
}
try {
const dialog = PromptMigrationPasswordComponent.open(this.dialogService);
const masterPassword = await firstValueFrom(dialog.closed);
if (Utils.isNullOrWhitespace(masterPassword)) {
await this.stateProvider.setUserState(ENCRYPTED_MIGRATION_DISMISSED, new Date(), userId);
} else {
await this.encryptedMigrator.runMigrations(
userId,
masterPassword === undefined ? null : masterPassword,
);
}
} catch (error) {
this.logService.error("[EncryptedMigrationsScheduler] Error during migration prompt", error);
// If migrations failed when the user actively was prompted, show a toast
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("migrationsFailed"),
});
}
}
}

View File

@@ -0,0 +1,55 @@
<form [bitSubmit]="submit" [formGroup]="migrationPasswordForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
{{ "updateEncryptionSettingsTitle" | i18n }}
</div>
<div bitDialogContent>
<p>
{{ "updateEncryptionSettingsDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/kdf-algorithms/"
target="_blank"
rel="noreferrer"
aria-label="external link"
>
{{ "learnMore" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</p>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<bit-hint>{{ "confirmIdentityToContinue" | i18n }}</bit-hint>
<input
class="tw-font-mono"
bitInput
type="password"
formControlName="masterPassword"
[attr.title]="'masterPass' | i18n"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[attr.title]="'toggleVisibility' | i18n"
[attr.aria-label]="'toggleVisibility' | i18n"
></button>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[disabled]="migrationPasswordForm.invalid"
>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "later" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,85 @@
import { CommonModule } from "@angular/common";
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { filter, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import {
LinkModule,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogRef,
DialogService,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
/**
* This is a generic prompt to run encryption migrations that require the master password.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "prompt-migration-password.component.html",
imports: [
DialogModule,
LinkModule,
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class PromptMigrationPasswordComponent {
private dialogRef = inject(DialogRef<string>);
private formBuilder = inject(FormBuilder);
private uvService = inject(UserVerificationService);
private accountService = inject(AccountService);
migrationPasswordForm = this.formBuilder.group({
masterPassword: ["", [Validators.required]],
});
static open(dialogService: DialogService) {
return dialogService.open<string>(PromptMigrationPasswordComponent);
}
submit = async () => {
const masterPasswordControl = this.migrationPasswordForm.controls.masterPassword;
if (!masterPasswordControl.value || masterPasswordControl.invalid) {
return;
}
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(
filter((account) => account != null),
map((account) => {
return {
userId: account!.id,
email: account!.email,
};
}),
),
);
if (
!(await this.uvService.verifyUserByMasterPassword(
{ type: VerificationType.MasterPassword, secret: masterPasswordControl.value },
userId,
email,
))
) {
return;
}
// Return the master password to the caller
this.dialogRef.close(masterPasswordControl.value);
};
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject } from "rxjs"; import { Subject } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -177,10 +178,12 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service"; import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction"; import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service"; import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
@@ -328,6 +331,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { import {
AnonLayoutWrapperDataService, AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService, DefaultAnonLayoutWrapperDataService,
DialogService,
ToastService, ToastService,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { import {
@@ -396,6 +400,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service"; import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction"; import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { DocumentLangSetter } from "../platform/i18n"; import { DocumentLangSetter } from "../platform/i18n";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -516,6 +522,23 @@ const safeProviders: SafeProvider[] = [
TokenServiceAbstraction, TokenServiceAbstraction,
], ],
}), }),
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [ChangeKdfApiService, SdkService],
}),
safeProvider({
provide: EncryptedMigrator,
useClass: DefaultEncryptedMigrator,
deps: [
KdfConfigService,
ChangeKdfService,
LogService,
ConfigService,
MasterPasswordServiceAbstraction,
SyncService,
],
}),
safeProvider({ safeProvider({
provide: LoginStrategyServiceAbstraction, provide: LoginStrategyServiceAbstraction,
useClass: LoginStrategyService, useClass: LoginStrategyService,
@@ -1665,6 +1688,7 @@ const safeProviders: SafeProvider[] = [
SsoLoginServiceAbstraction, SsoLoginServiceAbstraction,
SyncService, SyncService,
UserAsymmetricKeysRegenerationService, UserAsymmetricKeysRegenerationService,
EncryptedMigrator,
LogService, LogService,
], ],
}), }),
@@ -1735,6 +1759,28 @@ const safeProviders: SafeProvider[] = [
InternalMasterPasswordServiceAbstraction, InternalMasterPasswordServiceAbstraction,
], ],
}), }),
safeProvider({
provide: EncryptedMigrationsSchedulerService,
useClass: DefaultEncryptedMigrationsSchedulerService,
deps: [
SyncService,
AccountService,
StateProvider,
EncryptedMigrator,
AuthServiceAbstraction,
LogService,
DialogService,
ToastService,
I18nServiceAbstraction,
Router,
],
}),
safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
deps: [EncryptedMigrationsSchedulerService],
multi: true,
}),
safeProvider({ safeProvider({
provide: LockService, provide: LockService,
useClass: DefaultLockService, useClass: DefaultLockService,

View File

@@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} }
private async handleSuccessfulLoginNavigation(userId: UserId) { private async handleSuccessfulLoginNavigation(userId: UserId) {
await this.loginSuccessHandlerService.run(userId); await this.loginSuccessHandlerService.run(userId, null);
await this.router.navigate(["vault"]); await this.router.navigate(["vault"]);
} }
} }

View File

@@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy {
} }
// User logged in successfully so execute side effects // User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId); await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Determine where to send the user next // Determine where to send the user next
// The AuthGuard will handle routing to change-password based on state // The AuthGuard will handle routing to change-password based on state

View File

@@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return; return;
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginSuccessHandlerService.run(authResult.userId);
// TODO: PM-22663 use the new service to handle routing. // TODO: PM-22663 use the new service to handle routing.
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
return; return;
} }
await this.loginSuccessHandlerService.run(authenticationResult.userId); await this.loginSuccessHandlerService.run(
authenticationResult.userId,
authenticationResult.masterPassword ?? null,
);
if (this.premiumInterest) { if (this.premiumInterest) {
await this.premiumInterestStateService.setPremiumInterest( await this.premiumInterestStateService.setPremiumInterest(

View File

@@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
// Everything after the 2FA check is considered a successful login // Everything after the 2FA check is considered a successful login
// Just have to figure out where to send the user // Just have to figure out where to send the user
await this.loginSuccessHandlerService.run(authResult.userId); await this.loginSuccessHandlerService.run(authResult.userId, null);
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere) // Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
// - TDE login decryption options component // - TDE login decryption options component

View File

@@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
} }
// User is fully logged in so handle any post login logic before executing navigation // User is fully logged in so handle any post login logic before executing navigation
await this.loginSuccessHandlerService.run(authResult.userId); await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
// Save off the OrgSsoIdentifier for use in the TDE flows // Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component // - TDE login decryption options component

View File

@@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
* Runs any service calls required after a successful login. * Runs any service calls required after a successful login.
* Service calls that should be included in this method are only those required to be awaited after successful login. * Service calls that should be included in this method are only those required to be awaited after successful login.
* @param userId The user id. * @param userId The user id.
* @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods.
*/ */
abstract run(userId: UserId): Promise<void>; abstract run(userId: UserId, masterPassword: string | null): Promise<void>;
} }

View File

@@ -308,6 +308,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials); const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult(); const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId; expected.userId = userId;
expected.resetMasterPassword = true; expected.resetMasterPassword = true;
expected.twoFactorProviders = null; expected.twoFactorProviders = null;
@@ -323,6 +324,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials); const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult(); const expected = new AuthResult();
expected.masterPassword = "password";
expected.userId = userId; expected.userId = userId;
expected.resetMasterPassword = false; expected.resetMasterPassword = false;
expected.twoFactorProviders = null; expected.twoFactorProviders = null;

View File

@@ -108,6 +108,8 @@ export abstract class LoginStrategy {
data.tokenRequest.setTwoFactor(twoFactor); data.tokenRequest.setTwoFactor(twoFactor);
this.cache.next(data); this.cache.next(data);
const [authResult] = await this.startLogIn(); const [authResult] = await this.startLogIn();
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return authResult; return authResult;
} }
@@ -264,6 +266,9 @@ export abstract class LoginStrategy {
await this.processForceSetPasswordReason(response.forcePasswordReset, userId); await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
this.messagingService.send("loggedIn"); this.messagingService.send("loggedIn");
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
// TODO: https://bitwarden.atlassian.net/browse/PM-27573
result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
return result; return result;
} }

View File

@@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
localMasterKeyHash: string; localMasterKeyHash: string;
/** The user's master key */ /** The user's master key */
masterKey: MasterKey; masterKey: MasterKey;
/** The user's master password */
masterPassword: string;
/** /**
* Tracks if the user needs to update their password due to * Tracks if the user needs to update their password due to
* a password that does not meet an organization's master password policy. * a password that does not meet an organization's master password policy.
@@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
masterPassword, masterPassword,
email, email,
); );
data.masterPassword = masterPassword;
data.userEnteredEmail = email; data.userEnteredEmail = email;
// Hash the password early (before authentication) so we don't persist it in memory in plaintext // Hash the password early (before authentication) so we don't persist it in memory in plaintext
@@ -251,6 +254,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
this.cache.next(data); this.cache.next(data);
const [authResult] = await this.startLogIn(); const [authResult] = await this.startLogIn();
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
return authResult; return authResult;
} }

View File

@@ -1,6 +1,7 @@
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>; let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let syncService: MockProxy<SyncService>; let syncService: MockProxy<SyncService>;
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>; let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
let encryptedMigrator: MockProxy<EncryptedMigrator>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
const userId = "USER_ID" as UserId; const userId = "USER_ID" as UserId;
@@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService = mock<SsoLoginServiceAbstraction>(); ssoLoginService = mock<SsoLoginServiceAbstraction>();
syncService = mock<SyncService>(); syncService = mock<SyncService>();
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>(); userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
encryptedMigrator = mock<EncryptedMigrator>();
logService = mock<LogService>(); logService = mock<LogService>();
service = new DefaultLoginSuccessHandlerService( service = new DefaultLoginSuccessHandlerService(
@@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService, ssoLoginService,
syncService, syncService,
userAsymmetricKeysRegenerationService, userAsymmetricKeysRegenerationService,
encryptedMigrator,
logService, logService,
); );
@@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
describe("run", () => { describe("run", () => {
it("should call required services on successful login", async () => { it("should call required services on successful login", async () => {
await service.run(userId); await service.run(userId, null);
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true }); expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId); expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
@@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
}); });
it("should get SSO email", async () => { it("should get SSO email", async () => {
await service.run(userId); await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled(); expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
}); });
@@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => {
ssoLoginService.getSsoEmail.mockResolvedValue(null); ssoLoginService.getSsoEmail.mockResolvedValue(null);
}); });
it("should log error and return early", async () => { it("should not check SSO requirements", async () => {
await service.run(userId); await service.run(userId, null);
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found."); expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled(); expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
@@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
}); });
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => { it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
await service.run(userId); await service.run(userId, null);
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId); expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled(); expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();

View File

@@ -1,4 +1,5 @@
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
@@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
private ssoLoginService: SsoLoginServiceAbstraction, private ssoLoginService: SsoLoginServiceAbstraction,
private syncService: SyncService, private syncService: SyncService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService, private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private encryptedMigrator: EncryptedMigrator,
private logService: LogService, private logService: LogService,
) {} ) {}
async run(userId: UserId): Promise<void> {
async run(userId: UserId, masterPassword: string | null): Promise<void> {
await this.syncService.fullSync(true, { skipTokenRefresh: true }); await this.syncService.fullSync(true, { skipTokenRefresh: true });
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await this.loginEmailService.clearLoginEmail(); await this.loginEmailService.clearLoginEmail();
try {
await this.encryptedMigrator.runMigrations(userId, masterPassword);
} catch {
// Don't block login success on migration failure
}
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail(); const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();

View File

@@ -18,6 +18,8 @@ export class AuthResult {
email: string; email: string;
requiresEncryptionKeyMigration: boolean; requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean; requiresDeviceVerification: boolean;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() { get requiresTwoFactor() {
return this.twoFactorProviders != null; return this.twoFactorProviders != null;

View File

@@ -0,0 +1,194 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
import { EncryptedMigration } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
jest.mock("./migrations/minimum-kdf-migration");
describe("EncryptedMigrator", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const configService = mock<ConfigService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const syncService = mock<SyncService>();
let sut: DefaultEncryptedMigrator;
const mockMigration = mock<MinimumKdfMigration>();
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword123";
beforeEach(() => {
jest.clearAllMocks();
// Mock the MinimumKdfMigration constructor to return our mock
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
() => mockMigration,
);
sut = new DefaultEncryptedMigrator(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
configService,
masterPasswordService,
syncService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("runMigrations", () => {
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
});
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run migration when needsMigration returns 'needsMigration'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should throw error when migration needs master password but null is provided", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run multiple migrations", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("needsMigrations", () => {
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when at least one migration needs to run", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
});
});
});

View File

@@ -0,0 +1,113 @@
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../auth/utils";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
export class DefaultEncryptedMigrator implements EncryptedMigrator {
private migrations: { name: string; migration: EncryptedMigration }[] = [];
private isRunningMigration = false;
constructor(
readonly kdfConfigService: KdfConfigService,
readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
readonly configService: ConfigService,
readonly masterPasswordService: MasterPasswordServiceAbstraction,
readonly syncService: SyncService,
) {
// Register migrations here
this.migrations.push({
name: "Minimum PBKDF2 Iteration Count Migration",
migration: new MinimumKdfMigration(
kdfConfigService,
changeKdfService,
logService,
configService,
masterPasswordService,
),
});
}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
// Ensure that the requirements for running all migrations are met
const needsMigration = await this.needsMigrations(userId);
if (needsMigration === "noMigrationNeeded") {
return;
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
// password, such as biometric unlock, the migrations are skipped.
//
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
return;
}
try {
// No concurrent migrations allowed, so acquire a service-wide lock
if (this.isRunningMigration) {
return;
}
this.isRunningMigration = true;
// Run all migrations sequentially in the order they were registered
this.logService.mark("[Encrypted Migrator] Start");
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
let ranMigration = false;
for (const { name, migration } of this.migrations) {
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
const start = performance.now();
await migration.runMigrations(userId, masterPassword);
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
ranMigration = true;
}
}
this.logService.mark("[Encrypted Migrator] Finish");
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
if (ranMigration) {
await this.syncService.fullSync(true);
}
} catch (error) {
this.logService.error(
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
error,
);
throw error; // Re-throw the error to be handled by the caller
} finally {
this.isRunningMigration = false;
}
}
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
const migrationRequirements = await Promise.all(
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
);
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
return "needsMigrationWithMasterPassword";
} else if (migrationRequirements.includes("needsMigration")) {
return "needsMigration";
} else {
return "noMigrationNeeded";
}
}
isRunningMigrations(): boolean {
return this.isRunningMigration;
}
}

View File

@@ -0,0 +1,32 @@
import { UserId } from "../../types/guid";
import { MigrationRequirement } from "./migrations/encrypted-migration";
export abstract class EncryptedMigrator {
/**
* Runs migrations on a decrypted user, with the cryptographic state initialized.
* This only runs the migrations that are needed for the user.
* This needs to be run after the decrypted user key has been set to state.
*
* If the master password is required but not provided, the migrations will not run, and the function will return early.
* If migrations are already running, the migrations will not run again, and the function will return early.
*
* @param userId The ID of the user to run migrations for.
* @param masterPassword The user's current master password.
* @throws If the user does not exist
* @throws If the user is locked or logged out
* @throws If a migration fails
*/
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Checks if the user needs to run any migrations.
* This is used to determine if the user should be prompted to run migrations.
* @param userId The ID of the user to check migrations for.
*/
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
/**
* Indicates whether migrations are currently running.
*/
abstract isRunningMigrations(): boolean;
}

View File

@@ -0,0 +1,36 @@
import { UserId } from "../../../types/guid";
/**
* @internal
* IMPORTANT: Please read this when implementing new migrations.
*
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
* during actions such as login and unlock, or during sync.
*
* Migrations can require the master-password, which is provided by the user if required.
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
*
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
* are made for the server, and other devices may run the migration concurrently.
*
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
*/
export interface EncryptedMigration {
/**
* Runs the migration.
* @throws If the migration fails, such as when no network is available.
* @throws If the requirements for migration are not met (e.g. the user is locked)
*/
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
*/
needsMigration(userId: UserId): Promise<MigrationRequirement>;
}
export type MigrationRequirement =
| "needsMigration"
| "needsMigrationWithMasterPassword"
| "noMigrationNeeded";

View File

@@ -0,0 +1,184 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import {
Argon2KdfConfig,
KdfConfigService,
KdfType,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { UserId } from "../../../types/guid";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { MinimumKdfMigration } from "./minimum-kdf-migration";
describe("MinimumKdfMigration", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const mockConfigService = mock<ConfigService>();
const mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
let sut: MinimumKdfMigration;
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword";
beforeEach(() => {
jest.clearAllMocks();
sut = new MinimumKdfMigration(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
mockConfigService,
mockMasterPasswordService,
);
});
describe("needsMigration", () => {
it("should return 'noMigrationNeeded' when user does not have a master password`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when user uses argon2id`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true);
mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4));
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when feature flag is disabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigration(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId");
});
});
describe("runMigrations", () => {
it("should update KDF parameters with minimum PBKDF2 iterations", async () => {
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
// Verify the PBKDF2KdfConfig has the correct iteration count
const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1];
expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue);
});
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow(
"userId",
);
});
it("should throw error when masterPassword is null", async () => {
await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword");
});
it("should throw error when masterPassword is undefined", async () => {
await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow(
"masterPassword",
);
});
it("should handle errors from changeKdfService", async () => {
const mockError = new Error("KDF update failed");
mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError);
await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow(
"KDF update failed",
);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
});
});
});

View File

@@ -0,0 +1,68 @@
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../../auth/utils";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration";
/**
* @internal
* This migrator ensures the user's account has a minimum PBKDF2 iteration count.
* It will update the entire account, logging out old clients if necessary.
*/
export class MinimumKdfMigration implements EncryptedMigration {
constructor(
private readonly kdfConfigService: KdfConfigService,
private readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
private readonly configService: ConfigService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
) {}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
assertNonNullish(masterPassword, "masterPassword");
this.logService.info(
`[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`,
);
await this.changeKdfService.updateUserKdfParams(
masterPassword!,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
userId,
);
await this.kdfConfigService.setKdfConfig(
userId,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
);
}
async needsMigration(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
if (!(await this.masterPasswordService.userHasMasterPassword(userId))) {
return "noMigrationNeeded";
}
// Only PBKDF2 users below the minimum iteration count need migration
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
if (
kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 ||
kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min
) {
return "noMigrationNeeded";
}
if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) {
return "noMigrationNeeded";
}
return "needsMigrationWithMasterPassword";
}
}

View File

@@ -17,7 +17,7 @@ import {
} from "../master-password/types/master-password.types"; } from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction"; import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "./change-kdf-service"; import { DefaultChangeKdfService } from "./change-kdf.service";
describe("ChangeKdfService", () => { describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>(); const changeKdfApiService = mock<ChangeKdfApiService>();

View File

@@ -14,7 +14,7 @@ import {
} from "../master-password/types/master-password.types"; } from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction"; import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { ChangeKdfService } from "./change-kdf-service.abstraction"; import { ChangeKdfService } from "./change-kdf.service.abstraction";
export class DefaultChangeKdfService implements ChangeKdfService { export class DefaultChangeKdfService implements ChangeKdfService {
constructor( constructor(

View File

@@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction {
password: string, password: string,
masterPasswordUnlockData: MasterPasswordUnlockData, masterPasswordUnlockData: MasterPasswordUnlockData,
) => Promise<UserKey>; ) => Promise<UserKey>;
/**
* Returns whether the user has a master password set.
* @param userId The user ID.
* @throws If the user ID is missing.
*/
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
} }
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {

View File

@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
this.masterKeyHashSubject.next(initialMasterKeyHash); this.masterKeyHashSubject.next(initialMasterKeyHash);
} }
userHasMasterPassword(userId: UserId): Promise<boolean> {
return this.mock.userHasMasterPassword(userId);
}
emailToSalt(email: string): MasterPasswordSalt { emailToSalt(email: string): MasterPasswordSalt {
return this.mock.emailToSalt(email); return this.mock.emailToSalt(email);
} }

View File

@@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key";
import { KeyGenerationService } from "../../crypto"; import { KeyGenerationService } from "../../crypto";
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
import { EncryptedString, EncString } from "../../crypto/models/enc-string"; import { EncryptedString, EncString } from "../../crypto/models/enc-string";
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
import { import {
MasterKeyWrappedUserKey, MasterKeyWrappedUserKey,
@@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
private accountService: AccountService, private accountService: AccountService,
) {} ) {}
async userHasMasterPassword(userId: UserId): Promise<boolean> {
assertNonNullish(userId, "userId");
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
const usesKeyConnector = await firstValueFrom(
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
);
const usesMasterKey = await firstValueFrom(
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
);
return usesMasterKey && !usesKeyConnector;
}
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> { saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
assertNonNullish(userId, "userId"); assertNonNullish(userId, "userId");
return this.accountService.accounts$.pipe( return this.accountService.accounts$.pipe(
@@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
masterPasswordUnlockData.kdf.toSdkConfig(), masterPasswordUnlockData.kdf.toSdkConfig(),
), ),
); );
return userKey as UserKey; return userKey as UserKey;
} }

View File

@@ -22,6 +22,7 @@ import {
} from "@bitwarden/common/auth/types/verification"; } from "@bitwarden/common/auth/types/verification";
import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { ClientType, DeviceType } from "@bitwarden/common/enums";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -91,6 +92,7 @@ describe("LockComponent", () => {
const mockLockComponentService = mock<LockComponentService>(); const mockLockComponentService = mock<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>(); const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>(); const mockBroadcasterService = mock<BroadcasterService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockConfigService = mock<ConfigService>(); const mockConfigService = mock<ConfigService>();
beforeEach(async () => { beforeEach(async () => {
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService }, { provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService }, { provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
], ],
}) })

View File

@@ -31,6 +31,7 @@ import {
import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { ClientType, DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@@ -177,6 +178,8 @@ export class LockComponent implements OnInit, OnDestroy {
private logoutService: LogoutService, private logoutService: LogoutService,
private lockComponentService: LockComponentService, private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private encryptedMigrator: EncryptedMigrator,
private configService: ConfigService, private configService: ConfigService,
// desktop deps // desktop deps
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
@@ -639,6 +642,16 @@ export class LockComponent implements OnInit, OnDestroy {
} }
await this.biometricStateService.resetUserPromptCancelled(); await this.biometricStateService.resetUserPromptCancelled();
try {
await this.encryptedMigrator.runMigrations(
this.activeAccount.id,
afterUnlockActions.passwordEvaluation?.masterPassword ?? null,
);
} catch {
// Don't block login success on migration failure
}
this.messagingService.send("unlocked"); this.messagingService.send("unlocked");
if (afterUnlockActions.passwordEvaluation) { if (afterUnlockActions.passwordEvaluation) {

View File

@@ -54,8 +54,6 @@ export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk"
web: "disk-local", web: "disk-local",
browser: "disk-backup-local-storage", browser: "disk-backup-local-storage",
}); });
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
web: "disk-local", web: "disk-local",
}); });
@@ -64,8 +62,6 @@ export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memor
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk"); export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk");
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
export const ROUTER_DISK = new StateDefinition("router", "disk"); export const ROUTER_DISK = new StateDefinition("router", "disk");
export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const SSO_DISK_LOCAL = new StateDefinition("ssoLoginLocal", "disk", { web: "disk-local" }); export const SSO_DISK_LOCAL = new StateDefinition("ssoLoginLocal", "disk", { web: "disk-local" });
@@ -117,13 +113,10 @@ export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection",
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
web: "disk-local", web: "disk-local",
}); });
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
export const CONFIG_DISK = new StateDefinition("config", "disk", { export const CONFIG_DISK = new StateDefinition("config", "disk", {
web: "disk-local", web: "disk-local",
}); });
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk"); export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory"); export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
@@ -225,3 +218,14 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
"disk", "disk",
); );
export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory");
// KM
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
export const ENCRYPTED_MIGRATION_DISK = new StateDefinition("encryptedMigration", "disk");
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");