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": {
|
"learnMore": {
|
||||||
"message": "Learn more"
|
"message": "Learn more"
|
||||||
},
|
},
|
||||||
|
"migrationsFailed": {
|
||||||
|
"message": "An error occurred updating the encryption settings."
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsTitle": {
|
||||||
|
"message": "Update your encryption settings"
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsDesc": {
|
||||||
|
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
|
||||||
|
},
|
||||||
|
"confirmIdentityToContinue": {
|
||||||
|
"message": "Confirm your identity to continue"
|
||||||
|
},
|
||||||
|
"enterYourMasterPassword": {
|
||||||
|
"message": "Enter your master password"
|
||||||
|
},
|
||||||
|
"updateSettings": {
|
||||||
|
"message": "Update settings"
|
||||||
|
},
|
||||||
|
"later": {
|
||||||
|
"message": "Later"
|
||||||
|
},
|
||||||
"authenticatorKeyTotp": {
|
"authenticatorKeyTotp": {
|
||||||
"message": "Authenticator key (TOTP)"
|
"message": "Authenticator key (TOTP)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { TwoFactorService, TwoFactorApiService } from "@bitwarden/common/auth/tw
|
|||||||
import { ClientType } from "@bitwarden/common/enums";
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
@@ -81,6 +82,7 @@ export class LoginCommand {
|
|||||||
protected ssoUrlService: SsoUrlService,
|
protected ssoUrlService: SsoUrlService,
|
||||||
protected i18nService: I18nService,
|
protected i18nService: I18nService,
|
||||||
protected masterPasswordService: MasterPasswordServiceAbstraction,
|
protected masterPasswordService: MasterPasswordServiceAbstraction,
|
||||||
|
protected encryptedMigrator: EncryptedMigrator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async run(email: string, password: string, options: OptionValues) {
|
async run(email: string, password: string, options: OptionValues) {
|
||||||
@@ -367,6 +369,8 @@ export class LoginCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.encryptedMigrator.runMigrations(response.userId, password);
|
||||||
|
|
||||||
return await this.handleSuccessResponse(response);
|
return await this.handleSuccessResponse(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export abstract class BaseProgram {
|
|||||||
this.serviceContainer.organizationApiService,
|
this.serviceContainer.organizationApiService,
|
||||||
this.serviceContainer.logout,
|
this.serviceContainer.logout,
|
||||||
this.serviceContainer.i18nService,
|
this.serviceContainer.i18nService,
|
||||||
|
this.serviceContainer.encryptedMigrator,
|
||||||
this.serviceContainer.masterPasswordUnlockService,
|
this.serviceContainer.masterPasswordUnlockService,
|
||||||
this.serviceContainer.configService,
|
this.serviceContainer.configService,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
|||||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||||
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
|
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
@@ -40,6 +41,7 @@ describe("UnlockCommand", () => {
|
|||||||
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||||
const logout = jest.fn();
|
const logout = jest.fn();
|
||||||
const i18nService = mock<I18nService>();
|
const i18nService = mock<I18nService>();
|
||||||
|
const encryptedMigrator = mock<EncryptedMigrator>();
|
||||||
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
|
||||||
const configService = mock<ConfigService>();
|
const configService = mock<ConfigService>();
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ describe("UnlockCommand", () => {
|
|||||||
organizationApiService,
|
organizationApiService,
|
||||||
logout,
|
logout,
|
||||||
i18nService,
|
i18nService,
|
||||||
|
encryptedMigrator,
|
||||||
masterPasswordUnlockService,
|
masterPasswordUnlockService,
|
||||||
configService,
|
configService,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
|
|||||||
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
|
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
@@ -38,6 +39,7 @@ export class UnlockCommand {
|
|||||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||||
private logout: () => Promise<void>,
|
private logout: () => Promise<void>,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
|
private encryptedMigrator: EncryptedMigrator,
|
||||||
private masterPasswordUnlockService: MasterPasswordUnlockService,
|
private masterPasswordUnlockService: MasterPasswordUnlockService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
@@ -116,6 +118,8 @@ export class UnlockCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.encryptedMigrator.runMigrations(userId, password);
|
||||||
|
|
||||||
return this.successResponse();
|
return this.successResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export class OssServeConfigurator {
|
|||||||
this.serviceContainer.organizationApiService,
|
this.serviceContainer.organizationApiService,
|
||||||
async () => await this.serviceContainer.logout(),
|
async () => await this.serviceContainer.logout(),
|
||||||
this.serviceContainer.i18nService,
|
this.serviceContainer.i18nService,
|
||||||
|
this.serviceContainer.encryptedMigrator,
|
||||||
this.serviceContainer.masterPasswordUnlockService,
|
this.serviceContainer.masterPasswordUnlockService,
|
||||||
this.serviceContainer.configService,
|
this.serviceContainer.configService,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export class Program extends BaseProgram {
|
|||||||
this.serviceContainer.ssoUrlService,
|
this.serviceContainer.ssoUrlService,
|
||||||
this.serviceContainer.i18nService,
|
this.serviceContainer.i18nService,
|
||||||
this.serviceContainer.masterPasswordService,
|
this.serviceContainer.masterPasswordService,
|
||||||
|
this.serviceContainer.encryptedMigrator,
|
||||||
);
|
);
|
||||||
const response = await command.run(email, password, options);
|
const response = await command.run(email, password, options);
|
||||||
this.processResponse(response, true);
|
this.processResponse(response, true);
|
||||||
@@ -311,6 +312,7 @@ export class Program extends BaseProgram {
|
|||||||
this.serviceContainer.organizationApiService,
|
this.serviceContainer.organizationApiService,
|
||||||
async () => await this.serviceContainer.logout(),
|
async () => await this.serviceContainer.logout(),
|
||||||
this.serviceContainer.i18nService,
|
this.serviceContainer.i18nService,
|
||||||
|
this.serviceContainer.encryptedMigrator,
|
||||||
this.serviceContainer.masterPasswordUnlockService,
|
this.serviceContainer.masterPasswordUnlockService,
|
||||||
this.serviceContainer.configService,
|
this.serviceContainer.configService,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ import {
|
|||||||
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
|
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
||||||
|
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
|
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
|
||||||
|
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
@@ -324,6 +328,7 @@ export class ServiceContainer {
|
|||||||
cipherEncryptionService: CipherEncryptionService;
|
cipherEncryptionService: CipherEncryptionService;
|
||||||
restrictedItemTypesService: RestrictedItemTypesService;
|
restrictedItemTypesService: RestrictedItemTypesService;
|
||||||
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
|
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
|
||||||
|
encryptedMigrator: EncryptedMigrator;
|
||||||
securityStateService: SecurityStateService;
|
securityStateService: SecurityStateService;
|
||||||
masterPasswordUnlockService: MasterPasswordUnlockService;
|
masterPasswordUnlockService: MasterPasswordUnlockService;
|
||||||
cipherArchiveService: CipherArchiveService;
|
cipherArchiveService: CipherArchiveService;
|
||||||
@@ -975,6 +980,16 @@ export class ServiceContainer {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
|
this.masterPasswordApiService = new MasterPasswordApiService(this.apiService, this.logService);
|
||||||
|
const changeKdfApiService = new DefaultChangeKdfApiService(this.apiService);
|
||||||
|
const changeKdfService = new DefaultChangeKdfService(changeKdfApiService, this.sdkService);
|
||||||
|
this.encryptedMigrator = new DefaultEncryptedMigrator(
|
||||||
|
this.kdfConfigService,
|
||||||
|
changeKdfService,
|
||||||
|
this.logService,
|
||||||
|
this.configService,
|
||||||
|
this.masterPasswordService,
|
||||||
|
this.syncService,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
|||||||
@@ -1093,6 +1093,24 @@
|
|||||||
"learnMore": {
|
"learnMore": {
|
||||||
"message": "Learn more"
|
"message": "Learn more"
|
||||||
},
|
},
|
||||||
|
"migrationsFailed": {
|
||||||
|
"message": "An error occurred updating the encryption settings."
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsTitle": {
|
||||||
|
"message": "Update your encryption settings"
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsDesc": {
|
||||||
|
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
|
||||||
|
},
|
||||||
|
"confirmIdentityToContinue": {
|
||||||
|
"message": "Confirm your identity to continue"
|
||||||
|
},
|
||||||
|
"enterYourMasterPassword": {
|
||||||
|
"message": "Enter your master password"
|
||||||
|
},
|
||||||
|
"updateSettings": {
|
||||||
|
"message": "Update settings"
|
||||||
|
},
|
||||||
"featureUnavailable": {
|
"featureUnavailable": {
|
||||||
"message": "Feature unavailable"
|
"message": "Feature unavailable"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export class RecoverTwoFactorComponent implements OnInit {
|
|||||||
message: this.i18nService.t("twoStepRecoverDisabled"),
|
message: this.i18nService.t("twoStepRecoverDisabled"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
await this.loginSuccessHandlerService.run(authResult.userId, this.masterPassword);
|
||||||
|
|
||||||
await this.router.navigate(["/settings/security/two-factor"]);
|
await this.router.navigate(["/settings/security/two-factor"]);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
|||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { firstValueFrom, Observable } from "rxjs";
|
|||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
|||||||
@@ -4621,6 +4621,24 @@
|
|||||||
"learnMore": {
|
"learnMore": {
|
||||||
"message": "Learn more"
|
"message": "Learn more"
|
||||||
},
|
},
|
||||||
|
"migrationsFailed": {
|
||||||
|
"message": "An error occurred updating the encryption settings."
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsTitle": {
|
||||||
|
"message": "Update your encryption settings"
|
||||||
|
},
|
||||||
|
"updateEncryptionSettingsDesc": {
|
||||||
|
"message": "The new recommended encryption settings will improve your account security. Enter your master password to update now."
|
||||||
|
},
|
||||||
|
"confirmIdentityToContinue": {
|
||||||
|
"message": "Confirm your identity to continue"
|
||||||
|
},
|
||||||
|
"enterYourMasterPassword": {
|
||||||
|
"message": "Enter your master password"
|
||||||
|
},
|
||||||
|
"updateSettings": {
|
||||||
|
"message": "Update settings"
|
||||||
|
},
|
||||||
"deleteRecoverDesc": {
|
"deleteRecoverDesc": {
|
||||||
"message": "Enter your email address below to recover and delete your account."
|
"message": "Enter your email address below to recover and delete your account."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class LoginViaWebAuthnComponent implements OnInit {
|
|||||||
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
|
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
|
||||||
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
|
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
|
||||||
if (userKey) {
|
if (userKey) {
|
||||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
await this.loginSuccessHandlerService.run(authResult.userId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.router.navigate([this.successRoute]);
|
await this.router.navigate([this.successRoute]);
|
||||||
|
|||||||
@@ -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
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
import { APP_INITIALIZER, ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
import { Subject } from "rxjs";
|
import { Subject } from "rxjs";
|
||||||
|
|
||||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||||
@@ -177,10 +178,12 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
|
|||||||
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
|
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
|
||||||
|
import { DefaultEncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/default-encrypted-migrator";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
|
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
|
||||||
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
|
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
|
||||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
|
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||||
@@ -328,6 +331,7 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
|
|||||||
import {
|
import {
|
||||||
AnonLayoutWrapperDataService,
|
AnonLayoutWrapperDataService,
|
||||||
DefaultAnonLayoutWrapperDataService,
|
DefaultAnonLayoutWrapperDataService,
|
||||||
|
DialogService,
|
||||||
ToastService,
|
ToastService,
|
||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
import {
|
import {
|
||||||
@@ -396,6 +400,8 @@ import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from ".
|
|||||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||||
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
|
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
|
||||||
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
|
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||||
|
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
|
||||||
|
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
|
||||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||||
import { DocumentLangSetter } from "../platform/i18n";
|
import { DocumentLangSetter } from "../platform/i18n";
|
||||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||||
@@ -516,6 +522,23 @@ const safeProviders: SafeProvider[] = [
|
|||||||
TokenServiceAbstraction,
|
TokenServiceAbstraction,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: ChangeKdfService,
|
||||||
|
useClass: DefaultChangeKdfService,
|
||||||
|
deps: [ChangeKdfApiService, SdkService],
|
||||||
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: EncryptedMigrator,
|
||||||
|
useClass: DefaultEncryptedMigrator,
|
||||||
|
deps: [
|
||||||
|
KdfConfigService,
|
||||||
|
ChangeKdfService,
|
||||||
|
LogService,
|
||||||
|
ConfigService,
|
||||||
|
MasterPasswordServiceAbstraction,
|
||||||
|
SyncService,
|
||||||
|
],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: LoginStrategyServiceAbstraction,
|
provide: LoginStrategyServiceAbstraction,
|
||||||
useClass: LoginStrategyService,
|
useClass: LoginStrategyService,
|
||||||
@@ -1665,6 +1688,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
SsoLoginServiceAbstraction,
|
SsoLoginServiceAbstraction,
|
||||||
SyncService,
|
SyncService,
|
||||||
UserAsymmetricKeysRegenerationService,
|
UserAsymmetricKeysRegenerationService,
|
||||||
|
EncryptedMigrator,
|
||||||
LogService,
|
LogService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -1735,6 +1759,28 @@ const safeProviders: SafeProvider[] = [
|
|||||||
InternalMasterPasswordServiceAbstraction,
|
InternalMasterPasswordServiceAbstraction,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: EncryptedMigrationsSchedulerService,
|
||||||
|
useClass: DefaultEncryptedMigrationsSchedulerService,
|
||||||
|
deps: [
|
||||||
|
SyncService,
|
||||||
|
AccountService,
|
||||||
|
StateProvider,
|
||||||
|
EncryptedMigrator,
|
||||||
|
AuthServiceAbstraction,
|
||||||
|
LogService,
|
||||||
|
DialogService,
|
||||||
|
ToastService,
|
||||||
|
I18nServiceAbstraction,
|
||||||
|
Router,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: APP_INITIALIZER as SafeInjectionToken<() => Promise<void>>,
|
||||||
|
useFactory: (encryptedMigrationsScheduler: EncryptedMigrationsSchedulerService) => () => {},
|
||||||
|
deps: [EncryptedMigrationsSchedulerService],
|
||||||
|
multi: true,
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: LockService,
|
provide: LockService,
|
||||||
useClass: DefaultLockService,
|
useClass: DefaultLockService,
|
||||||
|
|||||||
@@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleSuccessfulLoginNavigation(userId: UserId) {
|
private async handleSuccessfulLoginNavigation(userId: UserId) {
|
||||||
await this.loginSuccessHandlerService.run(userId);
|
await this.loginSuccessHandlerService.run(userId, null);
|
||||||
await this.router.navigate(["vault"]);
|
await this.router.navigate(["vault"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User logged in successfully so execute side effects
|
// User logged in successfully so execute side effects
|
||||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||||
|
|
||||||
// Determine where to send the user next
|
// Determine where to send the user next
|
||||||
// The AuthGuard will handle routing to change-password based on state
|
// The AuthGuard will handle routing to change-password based on state
|
||||||
|
|||||||
@@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.loginSuccessHandlerService.run(authResult.userId);
|
|
||||||
|
|
||||||
// TODO: PM-22663 use the new service to handle routing.
|
// TODO: PM-22663 use the new service to handle routing.
|
||||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
await this.loginSuccessHandlerService.run(
|
||||||
|
authenticationResult.userId,
|
||||||
|
authenticationResult.masterPassword ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
if (this.premiumInterest) {
|
if (this.premiumInterest) {
|
||||||
await this.premiumInterestStateService.setPremiumInterest(
|
await this.premiumInterestStateService.setPremiumInterest(
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
|
|||||||
|
|
||||||
// Everything after the 2FA check is considered a successful login
|
// Everything after the 2FA check is considered a successful login
|
||||||
// Just have to figure out where to send the user
|
// Just have to figure out where to send the user
|
||||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
await this.loginSuccessHandlerService.run(authResult.userId, null);
|
||||||
|
|
||||||
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
|
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
|
||||||
// - TDE login decryption options component
|
// - TDE login decryption options component
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User is fully logged in so handle any post login logic before executing navigation
|
// User is fully logged in so handle any post login logic before executing navigation
|
||||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||||
|
|
||||||
// Save off the OrgSsoIdentifier for use in the TDE flows
|
// Save off the OrgSsoIdentifier for use in the TDE flows
|
||||||
// - TDE login decryption options component
|
// - TDE login decryption options component
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
|
|||||||
* Runs any service calls required after a successful login.
|
* Runs any service calls required after a successful login.
|
||||||
* Service calls that should be included in this method are only those required to be awaited after successful login.
|
* Service calls that should be included in this method are only those required to be awaited after successful login.
|
||||||
* @param userId The user id.
|
* @param userId The user id.
|
||||||
|
* @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods.
|
||||||
*/
|
*/
|
||||||
abstract run(userId: UserId): Promise<void>;
|
abstract run(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ describe("LoginStrategy", () => {
|
|||||||
const result = await passwordLoginStrategy.logIn(credentials);
|
const result = await passwordLoginStrategy.logIn(credentials);
|
||||||
|
|
||||||
const expected = new AuthResult();
|
const expected = new AuthResult();
|
||||||
|
expected.masterPassword = "password";
|
||||||
expected.userId = userId;
|
expected.userId = userId;
|
||||||
expected.resetMasterPassword = true;
|
expected.resetMasterPassword = true;
|
||||||
expected.twoFactorProviders = null;
|
expected.twoFactorProviders = null;
|
||||||
@@ -323,6 +324,7 @@ describe("LoginStrategy", () => {
|
|||||||
const result = await passwordLoginStrategy.logIn(credentials);
|
const result = await passwordLoginStrategy.logIn(credentials);
|
||||||
|
|
||||||
const expected = new AuthResult();
|
const expected = new AuthResult();
|
||||||
|
expected.masterPassword = "password";
|
||||||
expected.userId = userId;
|
expected.userId = userId;
|
||||||
expected.resetMasterPassword = false;
|
expected.resetMasterPassword = false;
|
||||||
expected.twoFactorProviders = null;
|
expected.twoFactorProviders = null;
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export abstract class LoginStrategy {
|
|||||||
data.tokenRequest.setTwoFactor(twoFactor);
|
data.tokenRequest.setTwoFactor(twoFactor);
|
||||||
this.cache.next(data);
|
this.cache.next(data);
|
||||||
const [authResult] = await this.startLogIn();
|
const [authResult] = await this.startLogIn();
|
||||||
|
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
|
||||||
|
authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
|
||||||
return authResult;
|
return authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +266,9 @@ export abstract class LoginStrategy {
|
|||||||
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
|
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
|
||||||
|
|
||||||
this.messagingService.send("loggedIn");
|
this.messagingService.send("loggedIn");
|
||||||
|
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
|
||||||
|
// TODO: https://bitwarden.atlassian.net/browse/PM-27573
|
||||||
|
result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
|
|||||||
localMasterKeyHash: string;
|
localMasterKeyHash: string;
|
||||||
/** The user's master key */
|
/** The user's master key */
|
||||||
masterKey: MasterKey;
|
masterKey: MasterKey;
|
||||||
|
/** The user's master password */
|
||||||
|
masterPassword: string;
|
||||||
/**
|
/**
|
||||||
* Tracks if the user needs to update their password due to
|
* Tracks if the user needs to update their password due to
|
||||||
* a password that does not meet an organization's master password policy.
|
* a password that does not meet an organization's master password policy.
|
||||||
@@ -83,6 +85,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
|||||||
masterPassword,
|
masterPassword,
|
||||||
email,
|
email,
|
||||||
);
|
);
|
||||||
|
data.masterPassword = masterPassword;
|
||||||
data.userEnteredEmail = email;
|
data.userEnteredEmail = email;
|
||||||
|
|
||||||
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
||||||
@@ -251,6 +254,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
|||||||
this.cache.next(data);
|
this.cache.next(data);
|
||||||
|
|
||||||
const [authResult] = await this.startLogIn();
|
const [authResult] = await this.startLogIn();
|
||||||
|
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
|
||||||
return authResult;
|
return authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MockProxy, mock } from "jest-mock-extended";
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
@@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||||
let syncService: MockProxy<SyncService>;
|
let syncService: MockProxy<SyncService>;
|
||||||
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||||
|
let encryptedMigrator: MockProxy<EncryptedMigrator>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
|
|
||||||
const userId = "USER_ID" as UserId;
|
const userId = "USER_ID" as UserId;
|
||||||
@@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||||
syncService = mock<SyncService>();
|
syncService = mock<SyncService>();
|
||||||
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||||
|
encryptedMigrator = mock<EncryptedMigrator>();
|
||||||
logService = mock<LogService>();
|
logService = mock<LogService>();
|
||||||
|
|
||||||
service = new DefaultLoginSuccessHandlerService(
|
service = new DefaultLoginSuccessHandlerService(
|
||||||
@@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
ssoLoginService,
|
ssoLoginService,
|
||||||
syncService,
|
syncService,
|
||||||
userAsymmetricKeysRegenerationService,
|
userAsymmetricKeysRegenerationService,
|
||||||
|
encryptedMigrator,
|
||||||
logService,
|
logService,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
|
|
||||||
describe("run", () => {
|
describe("run", () => {
|
||||||
it("should call required services on successful login", async () => {
|
it("should call required services on successful login", async () => {
|
||||||
await service.run(userId);
|
await service.run(userId, null);
|
||||||
|
|
||||||
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
|
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
|
||||||
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
|
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
|
||||||
@@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should get SSO email", async () => {
|
it("should get SSO email", async () => {
|
||||||
await service.run(userId);
|
await service.run(userId, null);
|
||||||
|
|
||||||
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
|
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -68,8 +72,8 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
ssoLoginService.getSsoEmail.mockResolvedValue(null);
|
ssoLoginService.getSsoEmail.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log error and return early", async () => {
|
it("should not check SSO requirements", async () => {
|
||||||
await service.run(userId);
|
await service.run(userId, null);
|
||||||
|
|
||||||
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
|
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
|
||||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||||
@@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
|
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
|
||||||
await service.run(userId);
|
await service.run(userId, null);
|
||||||
|
|
||||||
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
|
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
|
||||||
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
|
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
@@ -15,12 +16,19 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
|
|||||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||||
private syncService: SyncService,
|
private syncService: SyncService,
|
||||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||||
|
private encryptedMigrator: EncryptedMigrator,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
async run(userId: UserId): Promise<void> {
|
|
||||||
|
async run(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||||
await this.syncService.fullSync(true, { skipTokenRefresh: true });
|
await this.syncService.fullSync(true, { skipTokenRefresh: true });
|
||||||
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
||||||
await this.loginEmailService.clearLoginEmail();
|
await this.loginEmailService.clearLoginEmail();
|
||||||
|
try {
|
||||||
|
await this.encryptedMigrator.runMigrations(userId, masterPassword);
|
||||||
|
} catch {
|
||||||
|
// Don't block login success on migration failure
|
||||||
|
}
|
||||||
|
|
||||||
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
|
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export class AuthResult {
|
|||||||
email: string;
|
email: string;
|
||||||
requiresEncryptionKeyMigration: boolean;
|
requiresEncryptionKeyMigration: boolean;
|
||||||
requiresDeviceVerification: boolean;
|
requiresDeviceVerification: boolean;
|
||||||
|
// The master-password used in the authentication process
|
||||||
|
masterPassword: string | null;
|
||||||
|
|
||||||
get requiresTwoFactor() {
|
get requiresTwoFactor() {
|
||||||
return this.twoFactorProviders != null;
|
return this.twoFactorProviders != null;
|
||||||
|
|||||||
@@ -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";
|
} from "../master-password/types/master-password.types";
|
||||||
|
|
||||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||||
import { DefaultChangeKdfService } from "./change-kdf-service";
|
import { DefaultChangeKdfService } from "./change-kdf.service";
|
||||||
|
|
||||||
describe("ChangeKdfService", () => {
|
describe("ChangeKdfService", () => {
|
||||||
const changeKdfApiService = mock<ChangeKdfApiService>();
|
const changeKdfApiService = mock<ChangeKdfApiService>();
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "../master-password/types/master-password.types";
|
} from "../master-password/types/master-password.types";
|
||||||
|
|
||||||
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
|
||||||
import { ChangeKdfService } from "./change-kdf-service.abstraction";
|
import { ChangeKdfService } from "./change-kdf.service.abstraction";
|
||||||
|
|
||||||
export class DefaultChangeKdfService implements ChangeKdfService {
|
export class DefaultChangeKdfService implements ChangeKdfService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -106,6 +106,13 @@ export abstract class MasterPasswordServiceAbstraction {
|
|||||||
password: string,
|
password: string,
|
||||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||||
) => Promise<UserKey>;
|
) => Promise<UserKey>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the user has a master password set.
|
||||||
|
* @param userId The user ID.
|
||||||
|
* @throws If the user ID is missing.
|
||||||
|
*/
|
||||||
|
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {
|
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
|||||||
this.masterKeyHashSubject.next(initialMasterKeyHash);
|
this.masterKeyHashSubject.next(initialMasterKeyHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||||
|
return this.mock.userHasMasterPassword(userId);
|
||||||
|
}
|
||||||
|
|
||||||
emailToSalt(email: string): MasterPasswordSalt {
|
emailToSalt(email: string): MasterPasswordSalt {
|
||||||
return this.mock.emailToSalt(email);
|
return this.mock.emailToSalt(email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { MasterKey, UserKey } from "../../../types/key";
|
|||||||
import { KeyGenerationService } from "../../crypto";
|
import { KeyGenerationService } from "../../crypto";
|
||||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||||
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
|
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
|
||||||
|
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
|
||||||
import {
|
import {
|
||||||
MasterKeyWrappedUserKey,
|
MasterKeyWrappedUserKey,
|
||||||
@@ -85,6 +86,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async userHasMasterPassword(userId: UserId): Promise<boolean> {
|
||||||
|
assertNonNullish(userId, "userId");
|
||||||
|
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
|
||||||
|
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
|
||||||
|
const usesKeyConnector = await firstValueFrom(
|
||||||
|
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
|
||||||
|
);
|
||||||
|
const usesMasterKey = await firstValueFrom(
|
||||||
|
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
|
||||||
|
);
|
||||||
|
return usesMasterKey && !usesKeyConnector;
|
||||||
|
}
|
||||||
|
|
||||||
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
|
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
|
||||||
assertNonNullish(userId, "userId");
|
assertNonNullish(userId, "userId");
|
||||||
return this.accountService.accounts$.pipe(
|
return this.accountService.accounts$.pipe(
|
||||||
@@ -307,6 +321,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
|||||||
masterPasswordUnlockData.kdf.toSdkConfig(),
|
masterPasswordUnlockData.kdf.toSdkConfig(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return userKey as UserKey;
|
return userKey as UserKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@bitwarden/common/auth/types/verification";
|
} from "@bitwarden/common/auth/types/verification";
|
||||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
@@ -91,6 +92,7 @@ describe("LockComponent", () => {
|
|||||||
const mockLockComponentService = mock<LockComponentService>();
|
const mockLockComponentService = mock<LockComponentService>();
|
||||||
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||||
const mockBroadcasterService = mock<BroadcasterService>();
|
const mockBroadcasterService = mock<BroadcasterService>();
|
||||||
|
const mockEncryptedMigrator = mock<EncryptedMigrator>();
|
||||||
const mockConfigService = mock<ConfigService>();
|
const mockConfigService = mock<ConfigService>();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
|
|||||||
{ provide: LockComponentService, useValue: mockLockComponentService },
|
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||||
|
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
|
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
@@ -177,6 +178,8 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
private logoutService: LogoutService,
|
private logoutService: LogoutService,
|
||||||
private lockComponentService: LockComponentService,
|
private lockComponentService: LockComponentService,
|
||||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||||
|
private encryptedMigrator: EncryptedMigrator,
|
||||||
|
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
// desktop deps
|
// desktop deps
|
||||||
private broadcasterService: BroadcasterService,
|
private broadcasterService: BroadcasterService,
|
||||||
@@ -639,6 +642,16 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.biometricStateService.resetUserPromptCancelled();
|
await this.biometricStateService.resetUserPromptCancelled();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.encryptedMigrator.runMigrations(
|
||||||
|
this.activeAccount.id,
|
||||||
|
afterUnlockActions.passwordEvaluation?.masterPassword ?? null,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Don't block login success on migration failure
|
||||||
|
}
|
||||||
|
|
||||||
this.messagingService.send("unlocked");
|
this.messagingService.send("unlocked");
|
||||||
|
|
||||||
if (afterUnlockActions.passwordEvaluation) {
|
if (afterUnlockActions.passwordEvaluation) {
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk"
|
|||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
browser: "disk-backup-local-storage",
|
browser: "disk-backup-local-storage",
|
||||||
});
|
});
|
||||||
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
|
||||||
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
|
||||||
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
|
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
|
||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
});
|
});
|
||||||
@@ -64,8 +62,6 @@ export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memor
|
|||||||
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
|
||||||
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
|
||||||
export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk");
|
export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk");
|
||||||
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
|
|
||||||
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
|
|
||||||
export const ROUTER_DISK = new StateDefinition("router", "disk");
|
export const ROUTER_DISK = new StateDefinition("router", "disk");
|
||||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||||
export const SSO_DISK_LOCAL = new StateDefinition("ssoLoginLocal", "disk", { web: "disk-local" });
|
export const SSO_DISK_LOCAL = new StateDefinition("ssoLoginLocal", "disk", { web: "disk-local" });
|
||||||
@@ -117,13 +113,10 @@ export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection",
|
|||||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
});
|
});
|
||||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
|
||||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||||
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
export const CONFIG_DISK = new StateDefinition("config", "disk", {
|
||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
});
|
});
|
||||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
|
||||||
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
|
||||||
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk");
|
||||||
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
||||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||||
@@ -225,3 +218,14 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
|||||||
"disk",
|
"disk",
|
||||||
);
|
);
|
||||||
export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory");
|
export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory");
|
||||||
|
|
||||||
|
// KM
|
||||||
|
|
||||||
|
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||||
|
export const ENCRYPTED_MIGRATION_DISK = new StateDefinition("encryptedMigration", "disk");
|
||||||
|
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
|
||||||
|
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
|
||||||
|
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||||
|
export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory");
|
||||||
|
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
|
||||||
|
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
|
||||||
|
|||||||
Reference in New Issue
Block a user