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:
@@ -1406,6 +1406,27 @@
|
||||
"learnMore": {
|
||||
"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": {
|
||||
"message": "Authenticator key (TOTP)"
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
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 { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
@@ -81,6 +82,7 @@ export class LoginCommand {
|
||||
protected ssoUrlService: SsoUrlService,
|
||||
protected i18nService: I18nService,
|
||||
protected masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected encryptedMigrator: EncryptedMigrator,
|
||||
) {}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
if (
|
||||
|
||||
@@ -182,6 +182,7 @@ export abstract class BaseProgram {
|
||||
this.serviceContainer.organizationApiService,
|
||||
this.serviceContainer.logout,
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
|
||||
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 { 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";
|
||||
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
|
||||
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
const logout = jest.fn();
|
||||
const i18nService = mock<I18nService>();
|
||||
const encryptedMigrator = mock<EncryptedMigrator>();
|
||||
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
|
||||
organizationApiService,
|
||||
logout,
|
||||
i18nService,
|
||||
encryptedMigrator,
|
||||
masterPasswordUnlockService,
|
||||
configService,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
|
||||
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
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 { 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";
|
||||
@@ -38,6 +39,7 @@ export class UnlockCommand {
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private logout: () => Promise<void>,
|
||||
private i18nService: I18nService,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
private masterPasswordUnlockService: MasterPasswordUnlockService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
@@ -116,6 +118,8 @@ export class UnlockCommand {
|
||||
}
|
||||
}
|
||||
|
||||
await this.encryptedMigrator.runMigrations(userId, password);
|
||||
|
||||
return this.successResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export class OssServeConfigurator {
|
||||
this.serviceContainer.organizationApiService,
|
||||
async () => await this.serviceContainer.logout(),
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
|
||||
this.serviceContainer.ssoUrlService,
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.masterPasswordService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
);
|
||||
const response = await command.run(email, password, options);
|
||||
this.processResponse(response, true);
|
||||
@@ -311,6 +312,7 @@ export class Program extends BaseProgram {
|
||||
this.serviceContainer.organizationApiService,
|
||||
async () => await this.serviceContainer.logout(),
|
||||
this.serviceContainer.i18nService,
|
||||
this.serviceContainer.encryptedMigrator,
|
||||
this.serviceContainer.masterPasswordUnlockService,
|
||||
this.serviceContainer.configService,
|
||||
);
|
||||
|
||||
@@ -76,6 +76,10 @@ import {
|
||||
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 { 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 { 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";
|
||||
@@ -324,6 +328,7 @@ export class ServiceContainer {
|
||||
cipherEncryptionService: CipherEncryptionService;
|
||||
restrictedItemTypesService: RestrictedItemTypesService;
|
||||
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
|
||||
encryptedMigrator: EncryptedMigrator;
|
||||
securityStateService: SecurityStateService;
|
||||
masterPasswordUnlockService: MasterPasswordUnlockService;
|
||||
cipherArchiveService: CipherArchiveService;
|
||||
@@ -975,6 +980,16 @@ export class ServiceContainer {
|
||||
);
|
||||
|
||||
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() {
|
||||
|
||||
@@ -1093,6 +1093,24 @@
|
||||
"learnMore": {
|
||||
"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": {
|
||||
"message": "Feature unavailable"
|
||||
},
|
||||
|
||||
@@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit {
|
||||
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"]);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
|
||||
@@ -4621,6 +4621,24 @@
|
||||
"learnMore": {
|
||||
"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": {
|
||||
"message": "Enter your email address below to recover and delete your account."
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
|
||||
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
|
||||
if (userKey) {
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, null);
|
||||
}
|
||||
|
||||
await this.router.navigate([this.successRoute]);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @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";
|
||||
|
||||
// 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 { 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 { 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 { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||
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 } 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";
|
||||
@@ -328,6 +331,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
DefaultAnonLayoutWrapperDataService,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
@@ -396,6 +400,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
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 { 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 { DocumentLangSetter } from "../platform/i18n";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
@@ -516,6 +522,23 @@ const safeProviders: SafeProvider[] = [
|
||||
TokenServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ChangeKdfService,
|
||||
useClass: DefaultChangeKdfService,
|
||||
deps: [ChangeKdfApiService, SdkService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EncryptedMigrator,
|
||||
useClass: DefaultEncryptedMigrator,
|
||||
deps: [
|
||||
KdfConfigService,
|
||||
ChangeKdfService,
|
||||
LogService,
|
||||
ConfigService,
|
||||
MasterPasswordServiceAbstraction,
|
||||
SyncService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginStrategyServiceAbstraction,
|
||||
useClass: LoginStrategyService,
|
||||
@@ -1665,6 +1688,7 @@ const safeProviders: SafeProvider[] = [
|
||||
SsoLoginServiceAbstraction,
|
||||
SyncService,
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
EncryptedMigrator,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
@@ -1735,6 +1759,28 @@ const safeProviders: SafeProvider[] = [
|
||||
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({
|
||||
provide: LockService,
|
||||
useClass: DefaultLockService,
|
||||
|
||||
@@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation(userId: UserId) {
|
||||
await this.loginSuccessHandlerService.run(userId);
|
||||
await this.loginSuccessHandlerService.run(userId, null);
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// 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
|
||||
// The AuthGuard will handle routing to change-password based on state
|
||||
|
||||
@@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||
|
||||
// TODO: PM-22663 use the new service to handle routing.
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
@@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||
await this.loginSuccessHandlerService.run(
|
||||
authenticationResult.userId,
|
||||
authenticationResult.masterPassword ?? null,
|
||||
);
|
||||
|
||||
if (this.premiumInterest) {
|
||||
await this.premiumInterestStateService.setPremiumInterest(
|
||||
|
||||
@@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
|
||||
|
||||
// Everything after the 2FA check is considered a successful login
|
||||
// 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)
|
||||
// - TDE login decryption options component
|
||||
|
||||
@@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// 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
|
||||
// - TDE login decryption options component
|
||||
|
||||
@@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
|
||||
* 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.
|
||||
* @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>;
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ describe("LoginStrategy", () => {
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
const expected = new AuthResult();
|
||||
expected.masterPassword = "password";
|
||||
expected.userId = userId;
|
||||
expected.resetMasterPassword = true;
|
||||
expected.twoFactorProviders = null;
|
||||
@@ -323,6 +324,7 @@ describe("LoginStrategy", () => {
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
const expected = new AuthResult();
|
||||
expected.masterPassword = "password";
|
||||
expected.userId = userId;
|
||||
expected.resetMasterPassword = false;
|
||||
expected.twoFactorProviders = null;
|
||||
|
||||
@@ -108,6 +108,8 @@ export abstract class LoginStrategy {
|
||||
data.tokenRequest.setTwoFactor(twoFactor);
|
||||
this.cache.next(data);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -264,6 +266,9 @@ export abstract class LoginStrategy {
|
||||
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
localMasterKeyHash: string;
|
||||
/** The user's master key */
|
||||
masterKey: MasterKey;
|
||||
/** The user's master password */
|
||||
masterPassword: string;
|
||||
/**
|
||||
* Tracks if the user needs to update their password due to
|
||||
* a password that does not meet an organization's master password policy.
|
||||
@@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
masterPassword,
|
||||
email,
|
||||
);
|
||||
data.masterPassword = masterPassword;
|
||||
data.userEnteredEmail = email;
|
||||
|
||||
// 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);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
|
||||
return authResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||
let encryptedMigrator: MockProxy<EncryptedMigrator>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const userId = "USER_ID" as UserId;
|
||||
@@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
syncService = mock<SyncService>();
|
||||
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||
encryptedMigrator = mock<EncryptedMigrator>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
service = new DefaultLoginSuccessHandlerService(
|
||||
@@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService,
|
||||
syncService,
|
||||
userAsymmetricKeysRegenerationService,
|
||||
encryptedMigrator,
|
||||
logService,
|
||||
);
|
||||
|
||||
@@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
|
||||
describe("run", () => {
|
||||
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(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
|
||||
@@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
});
|
||||
|
||||
it("should get SSO email", async () => {
|
||||
await service.run(userId);
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
|
||||
});
|
||||
@@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService.getSsoEmail.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should log error and return early", async () => {
|
||||
await service.run(userId);
|
||||
it("should not check SSO requirements", async () => {
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||
@@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
});
|
||||
|
||||
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
|
||||
await service.run(userId);
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
|
||||
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private syncService: SyncService,
|
||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
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.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
||||
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();
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export class AuthResult {
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
// The master-password used in the authentication process
|
||||
masterPassword: string | null;
|
||||
|
||||
get requiresTwoFactor() {
|
||||
return this.twoFactorProviders != null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "./change-kdf-service";
|
||||
import { DefaultChangeKdfService } from "./change-kdf.service";
|
||||
|
||||
describe("ChangeKdfService", () => {
|
||||
const changeKdfApiService = mock<ChangeKdfApiService>();
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../master-password/types/master-password.types";
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
@@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction {
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
) => 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 {
|
||||
|
||||
@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
this.masterKeyHashSubject.next(initialMasterKeyHash);
|
||||
}
|
||||
|
||||
userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||
return this.mock.userHasMasterPassword(userId);
|
||||
}
|
||||
|
||||
emailToSalt(email: string): MasterPasswordSalt {
|
||||
return this.mock.emailToSalt(email);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key";
|
||||
import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
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 {
|
||||
MasterKeyWrappedUserKey,
|
||||
@@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
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> {
|
||||
assertNonNullish(userId, "userId");
|
||||
return this.accountService.accounts$.pipe(
|
||||
@@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
masterPasswordUnlockData.kdf.toSdkConfig(),
|
||||
),
|
||||
);
|
||||
|
||||
return userKey as UserKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@bitwarden/common/auth/types/verification";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
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 { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
@@ -91,6 +92,7 @@ describe("LockComponent", () => {
|
||||
const mockLockComponentService = mock<LockComponentService>();
|
||||
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||
const mockBroadcasterService = mock<BroadcasterService>();
|
||||
const mockEncryptedMigrator = mock<EncryptedMigrator>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
|
||||
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
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 { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
@@ -177,6 +178,8 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
private logoutService: LogoutService,
|
||||
private lockComponentService: LockComponentService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
|
||||
private configService: ConfigService,
|
||||
// desktop deps
|
||||
private broadcasterService: BroadcasterService,
|
||||
@@ -639,6 +642,16 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
if (afterUnlockActions.passwordEvaluation) {
|
||||
|
||||
@@ -54,8 +54,6 @@ export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk"
|
||||
web: "disk-local",
|
||||
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", {
|
||||
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_MEMORY = new StateDefinition("masterPassword", "memory");
|
||||
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 SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
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", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
||||
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 ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||
@@ -225,3 +218,14 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
||||
"disk",
|
||||
);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user