1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-23 16:38:45 +01:00

[PM-12700] Add private key regeneration process (#11829)

* add user asymmetric key api service

* Add user asymmetric key regen service

* add feature flag

* Add LoginSuccessHandlerService

* add loginSuccessHandlerService to BaseLoginViaWebAuthnComponent

* Only run loginSuccessHandlerService if webAuthn is used for vault decryption.

* Updates for TS strict

* bump SDK version

* swap to combineLatest

* Update abstractions
This commit is contained in:
Thomas Avery 2024-12-16 12:00:17 -06:00 committed by GitHub
parent c628f541d1
commit 971c157f56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 629 additions and 19 deletions

View File

@ -2,7 +2,9 @@
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
@ -10,6 +12,7 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response"
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { KeyService } from "@bitwarden/key-management";
export type State = "assert" | "assertFailed";
@ -26,6 +29,8 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
private logService: LogService,
private validationService: ValidationService,
private i18nService: I18nService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private keyService: KeyService,
) {}
ngOnInit(): void {
@ -59,11 +64,21 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
this.i18nService.t("twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn"),
);
this.currentState = "assertFailed";
} else if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.router.navigate([this.forcePasswordResetRoute]);
} else {
await this.router.navigate([this.successRoute]);
return;
}
// Only run loginSuccessHandlerService if webAuthn is used for vault decryption.
const userKey = await firstValueFrom(this.keyService.userKey$(authResult.userId));
if (userKey) {
await this.loginSuccessHandlerService.run(authResult.userId);
}
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.router.navigate([this.forcePasswordResetRoute]);
return;
}
await this.router.navigate([this.successRoute]);
} catch (error) {
if (error instanceof ErrorResponse) {
this.validationService.showError(this.i18nService.t("invalidPasskeyPleaseTryAgain"));

View File

@ -37,6 +37,8 @@ import {
RegisterRouteService,
AuthRequestApiService,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@ -281,6 +283,10 @@ import {
DefaultBiometricStateService,
KdfConfigService,
DefaultKdfConfigService,
UserAsymmetricKeysRegenerationService,
DefaultUserAsymmetricKeysRegenerationService,
UserAsymmetricKeysRegenerationApiService,
DefaultUserAsymmetricKeysRegenerationApiService,
} from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
import {
@ -1395,6 +1401,29 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: UserAsymmetricKeysRegenerationApiService,
useClass: DefaultUserAsymmetricKeysRegenerationApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: UserAsymmetricKeysRegenerationService,
useClass: DefaultUserAsymmetricKeysRegenerationService,
deps: [
KeyServiceAbstraction,
CipherServiceAbstraction,
UserAsymmetricKeysRegenerationApiService,
LogService,
SdkService,
ApiServiceAbstraction,
ConfigService,
],
}),
safeProvider({
provide: LoginSuccessHandlerService,
useClass: DefaultLoginSuccessHandlerService,
deps: [SyncService, UserAsymmetricKeysRegenerationService],
}),
];
@NgModule({

View File

@ -37,7 +37,11 @@ import {
IconButtonModule,
ToastService,
} from "@bitwarden/components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
import {
KeyService,
BiometricStateService,
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../common/abstractions";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
@ -139,6 +143,7 @@ export class LockV2Component implements OnInit, OnDestroy {
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private formBuilder: FormBuilder,
private toastService: ToastService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
@ -532,6 +537,8 @@ export class LockV2Component implements OnInit, OnDestroy {
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
await this.syncService.fullSync(false);
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(this.activeAccount.id);
if (this.clientType === "browser") {
const previousUrl = this.lockComponentService.getPreviousUrl();
/**

View File

@ -12,6 +12,7 @@ import {
AuthRequestServiceAbstraction,
LoginEmailServiceAbstraction,
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
} from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
@ -34,7 +35,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@ -88,9 +88,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {
this.clientType = this.platformUtilsService.getClientType();
@ -485,7 +485,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
await this.handleSuccessfulLoginNavigation();
await this.handleSuccessfulLoginNavigation(userId);
}
/**
@ -555,17 +555,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} else if (loginResponse.forcePasswordReset != ForceSetPasswordReason.None) {
await this.router.navigate(["update-temp-password"]);
} else {
await this.handleSuccessfulLoginNavigation();
await this.handleSuccessfulLoginNavigation(loginResponse.userId);
}
}
private async handleSuccessfulLoginNavigation() {
private async handleSuccessfulLoginNavigation(userId: UserId) {
if (this.flow === Flow.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.loginEmailService.saveEmailSettings();
}
await this.syncService.fullSync(true);
await this.loginSuccessHandlerService.run(userId);
await this.router.navigate(["vault"]);
}
}

View File

@ -10,6 +10,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
LoginEmailServiceAbstraction,
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
PasswordLoginCredentials,
RegisterRouteService,
} from "@bitwarden/auth/common";
@ -31,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import {
AsyncActionsModule,
@ -127,11 +127,11 @@ export class LoginComponent implements OnInit, OnDestroy {
private policyService: InternalPolicyService,
private registerRouteService: RegisterRouteService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
private logService: LogService,
private validationService: ValidationService,
private configService: ConfigService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@ -280,7 +280,7 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
await this.syncService.fullSync(true);
await this.loginSuccessHandlerService.run(authResult.userId);
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
this.loginEmailService.clearValues();

View File

@ -5,3 +5,4 @@ export * from "./login-strategy.service";
export * from "./user-decryption-options.service.abstraction";
export * from "./auth-request.service.abstraction";
export * from "./login-approval-component.service.abstraction";
export * from "./login-success-handler.service";

View File

@ -0,0 +1,10 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class LoginSuccessHandlerService {
/**
* Runs any service calls required after a successful login.
* Service calls that should be included in this method are only those required to be awaited after successful login.
* @param userId The user id.
*/
abstract run(userId: UserId): Promise<void>;
}

View File

@ -6,3 +6,4 @@ export * from "./auth-request/auth-request.service";
export * from "./auth-request/auth-request-api.service";
export * from "./register-route.service";
export * from "./accounts/lock.service";
export * from "./login-success-handler/default-login-success-handler.service";

View File

@ -0,0 +1,16 @@
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
import { LoginSuccessHandlerService } from "../../abstractions/login-success-handler.service";
export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerService {
constructor(
private syncService: SyncService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
) {}
async run(userId: UserId): Promise<void> {
await this.syncService.fullSync(true);
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
}
}

View File

@ -42,6 +42,7 @@ export enum FeatureFlag {
MacOsNativeCredentialSync = "macos-native-credential-sync",
PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission",
PM12443RemovePagingLogic = "pm-12443-remove-paging-logic",
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -94,6 +95,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE,
[FeatureFlag.PM12443RemovePagingLogic]: FALSE,
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@ -17,3 +17,5 @@ export {
export { KdfConfigService } from "./abstractions/kdf-config.service";
export { DefaultKdfConfigService } from "./kdf-config.service";
export { KdfType } from "./enums/kdf-type.enum";
export * from "./user-asymmetric-key-regeneration";

View File

@ -0,0 +1,8 @@
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
export abstract class UserAsymmetricKeysRegenerationApiService {
abstract regenerateUserAsymmetricKeys(
userPublicKey: string,
userKeyEncryptedUserPrivateKey: EncString,
): Promise<void>;
}

View File

@ -0,0 +1,10 @@
import { UserId } from "@bitwarden/common/types/guid";
export abstract class UserAsymmetricKeysRegenerationService {
/**
* Attempts to regenerate the user's asymmetric keys if they are invalid.
* Requires the PrivateKeyRegeneration feature flag to be enabled if not the method will do nothing.
* @param userId The user id.
*/
abstract regenerateIfNeeded(userId: UserId): Promise<void>;
}

View File

@ -0,0 +1,5 @@
export { UserAsymmetricKeysRegenerationService } from "./abstractions/user-asymmetric-key-regeneration.service";
export { DefaultUserAsymmetricKeysRegenerationService } from "./services/default-user-asymmetric-key-regeneration.service";
export { UserAsymmetricKeysRegenerationApiService } from "./abstractions/user-asymmetric-key-regeneration-api.service";
export { DefaultUserAsymmetricKeysRegenerationApiService } from "./services/default-user-asymmetric-key-regeneration-api.service";

View File

@ -0,0 +1,11 @@
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
export class KeyRegenerationRequest {
userPublicKey: string;
userKeyEncryptedUserPrivateKey: EncString;
constructor(userPublicKey: string, userKeyEncryptedUserPrivateKey: EncString) {
this.userPublicKey = userPublicKey;
this.userKeyEncryptedUserPrivateKey = userKeyEncryptedUserPrivateKey;
}
}

View File

@ -0,0 +1,29 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service";
import { KeyRegenerationRequest } from "../models/requests/key-regeneration.request";
export class DefaultUserAsymmetricKeysRegenerationApiService
implements UserAsymmetricKeysRegenerationApiService
{
constructor(private apiService: ApiService) {}
async regenerateUserAsymmetricKeys(
userPublicKey: string,
userKeyEncryptedUserPrivateKey: EncString,
): Promise<void> {
const request: KeyRegenerationRequest = {
userPublicKey,
userKeyEncryptedUserPrivateKey,
};
await this.apiService.send(
"POST",
"/accounts/key-management/regenerate-keys",
request,
true,
true,
);
}
}

View File

@ -0,0 +1,306 @@
import { MockProxy, mock } from "jest-mock-extended";
import { of, throwError } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { makeStaticByteArray, mockEnc } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { BitwardenClient, VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal";
import { KeyService } from "../../abstractions/key.service";
import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service";
import { DefaultUserAsymmetricKeysRegenerationService } from "./default-user-asymmetric-key-regeneration.service";
function setupVerificationResponse(
mockVerificationResponse: VerifyAsymmetricKeysResponse,
sdkService: MockProxy<SdkService>,
) {
const mockKeyPairResponse = {
userPublicKey: "userPublicKey",
userKeyEncryptedPrivateKey: "userKeyEncryptedPrivateKey",
};
sdkService.client$ = of({
crypto: () => ({
verify_asymmetric_keys: jest.fn().mockReturnValue(mockVerificationResponse),
make_key_pair: jest.fn().mockReturnValue(mockKeyPairResponse),
}),
free: jest.fn(),
echo: jest.fn(),
version: jest.fn(),
throw: jest.fn(),
catch: jest.fn(),
} as unknown as BitwardenClient);
}
function setupUserKeyValidation(
cipherService: MockProxy<CipherService>,
keyService: MockProxy<KeyService>,
encryptService: MockProxy<EncryptService>,
) {
const cipher = new Cipher();
cipher.id = "id";
cipher.edit = true;
cipher.viewPassword = true;
cipher.favorite = false;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.key = mockEnc("EncKey");
cipherService.getAll.mockResolvedValue([cipher]);
encryptService.decryptToBytes.mockResolvedValue(makeStaticByteArray(64));
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
}
describe("regenerateIfNeeded", () => {
let sut: DefaultUserAsymmetricKeysRegenerationService;
const userId = "userId" as UserId;
let keyService: MockProxy<KeyService>;
let cipherService: MockProxy<CipherService>;
let userAsymmetricKeysRegenerationApiService: MockProxy<UserAsymmetricKeysRegenerationApiService>;
let logService: MockProxy<LogService>;
let sdkService: MockProxy<SdkService>;
let apiService: MockProxy<ApiService>;
let configService: MockProxy<ConfigService>;
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
keyService = mock<KeyService>();
cipherService = mock<CipherService>();
userAsymmetricKeysRegenerationApiService = mock<UserAsymmetricKeysRegenerationApiService>();
logService = mock<LogService>();
sdkService = mock<SdkService>();
apiService = mock<ApiService>();
configService = mock<ConfigService>();
encryptService = mock<EncryptService>();
sut = new DefaultUserAsymmetricKeysRegenerationService(
keyService,
cipherService,
userAsymmetricKeysRegenerationApiService,
logService,
sdkService,
apiService,
configService,
);
configService.getFeatureFlag.mockResolvedValue(true);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockEncryptedString = new SymmetricCryptoKey(
mockRandomBytes,
).toString() as EncryptedString;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
keyService.userKey$.mockReturnValue(of(mockUserKey));
keyService.userEncryptedPrivateKey$.mockReturnValue(of(mockEncryptedString));
apiService.getUserPublicKey.mockResolvedValue({
userId: "userId",
publicKey: "publicKey",
} as any);
});
afterEach(() => {
jest.resetAllMocks();
});
it("should not call regeneration code when feature flag is off", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await sut.regenerateIfNeeded(userId);
expect(keyService.userKey$).not.toHaveBeenCalled();
});
it("should not regenerate when top level error is thrown", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: true,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
keyService.userKey$.mockReturnValue(throwError(() => new Error("error")));
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should not regenerate when private key is decryptable and valid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: true,
validPrivateKey: true,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should regenerate when private key is decryptable and invalid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: true,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).toHaveBeenCalled();
expect(keyService.setPrivateKey).toHaveBeenCalled();
});
it("should not set private key on known API error", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: true,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys.mockRejectedValue(
new Error("Key regeneration not supported for this user."),
);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should not set private key on unknown API error", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: true,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys.mockRejectedValue(
new Error("error"),
);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should regenerate when private key is not decryptable and user key is valid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: true,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
setupUserKeyValidation(cipherService, keyService, encryptService);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).toHaveBeenCalled();
expect(keyService.setPrivateKey).toHaveBeenCalled();
});
it("should not regenerate when private key is not decryptable and user key is invalid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: true,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
setupUserKeyValidation(cipherService, keyService, encryptService);
encryptService.decryptToBytes.mockRejectedValue(new Error("error"));
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should not regenerate when private key is not decryptable and no ciphers to check", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: true,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
cipherService.getAll.mockResolvedValue([]);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should regenerate when private key is not decryptable and invalid and user key is valid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
setupUserKeyValidation(cipherService, keyService, encryptService);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).toHaveBeenCalled();
expect(keyService.setPrivateKey).toHaveBeenCalled();
});
it("should not regenerate when private key is not decryptable and invalid and user key is invalid", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
setupUserKeyValidation(cipherService, keyService, encryptService);
encryptService.decryptToBytes.mockRejectedValue(new Error("error"));
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should not regenerate when private key is not decryptable and invalid and no ciphers to check", async () => {
const mockVerificationResponse: VerifyAsymmetricKeysResponse = {
privateKeyDecryptable: false,
validPrivateKey: false,
};
setupVerificationResponse(mockVerificationResponse, sdkService);
cipherService.getAll.mockResolvedValue([]);
await sut.regenerateIfNeeded(userId);
expect(
userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,158 @@
import { combineLatest, firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { KeyService } from "../../abstractions/key.service";
import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service";
import { UserAsymmetricKeysRegenerationService } from "../abstractions/user-asymmetric-key-regeneration.service";
export class DefaultUserAsymmetricKeysRegenerationService
implements UserAsymmetricKeysRegenerationService
{
constructor(
private keyService: KeyService,
private cipherService: CipherService,
private userAsymmetricKeysRegenerationApiService: UserAsymmetricKeysRegenerationApiService,
private logService: LogService,
private sdkService: SdkService,
private apiService: ApiService,
private configService: ConfigService,
) {}
async regenerateIfNeeded(userId: UserId): Promise<void> {
try {
const privateKeyRegenerationFlag = await this.configService.getFeatureFlag(
FeatureFlag.PrivateKeyRegeneration,
);
if (privateKeyRegenerationFlag) {
const shouldRegenerate = await this.shouldRegenerate(userId);
if (shouldRegenerate) {
await this.regenerateUserAsymmetricKeys(userId);
}
}
} catch (error) {
this.logService.error(
"[UserAsymmetricKeyRegeneration] An error occurred: " +
error +
" Skipping regeneration for the user.",
);
}
}
private async shouldRegenerate(userId: UserId): Promise<boolean> {
const [userKey, userKeyEncryptedPrivateKey, publicKeyResponse] = await firstValueFrom(
combineLatest([
this.keyService.userKey$(userId),
this.keyService.userEncryptedPrivateKey$(userId),
this.apiService.getUserPublicKey(userId),
]),
);
const verificationResponse = await firstValueFrom(
this.sdkService.client$.pipe(
map((sdk) => {
if (sdk === undefined) {
throw new Error("SDK is undefined");
}
return sdk.crypto().verify_asymmetric_keys({
userKey: userKey.keyB64,
userPublicKey: publicKeyResponse.publicKey,
userKeyEncryptedPrivateKey: userKeyEncryptedPrivateKey,
});
}),
),
);
if (verificationResponse.privateKeyDecryptable) {
if (verificationResponse.validPrivateKey) {
// The private key is decryptable and valid. Should not regenerate.
return false;
} else {
// The private key is decryptable but not valid so we should regenerate it.
this.logService.info(
"[UserAsymmetricKeyRegeneration] User's private key is decryptable but not a valid key, attempting regeneration.",
);
return true;
}
}
// The private isn't decryptable, check to see if we can decrypt something with the userKey.
const userKeyCanDecrypt = await this.userKeyCanDecrypt(userKey);
if (userKeyCanDecrypt) {
this.logService.info(
"[UserAsymmetricKeyRegeneration] User Asymmetric Key decryption failure detected, attempting regeneration.",
);
return true;
}
this.logService.warning(
"[UserAsymmetricKeyRegeneration] User Asymmetric Key decryption failure detected, but unable to determine User Symmetric Key validity, skipping regeneration.",
);
return false;
}
private async regenerateUserAsymmetricKeys(userId: UserId): Promise<void> {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
const makeKeyPairResponse = await firstValueFrom(
this.sdkService.client$.pipe(
map((sdk) => {
if (sdk === undefined) {
throw new Error("SDK is undefined");
}
return sdk.crypto().make_key_pair(userKey.keyB64);
}),
),
);
try {
await this.userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys(
makeKeyPairResponse.userPublicKey,
new EncString(makeKeyPairResponse.userKeyEncryptedPrivateKey),
);
} catch (error: any) {
if (error?.message === "Key regeneration not supported for this user.") {
this.logService.info(
"[UserAsymmetricKeyRegeneration] Regeneration not supported for this user at this time.",
);
} else {
this.logService.error(
"[UserAsymmetricKeyRegeneration] Regeneration error when submitting the request to the server: " +
error,
);
}
return;
}
await this.keyService.setPrivateKey(makeKeyPairResponse.userKeyEncryptedPrivateKey, userId);
this.logService.info(
"[UserAsymmetricKeyRegeneration] User's asymmetric keys successfully regenerated.",
);
}
private async userKeyCanDecrypt(userKey: UserKey): Promise<boolean> {
const ciphers = await this.cipherService.getAll();
const cipher = ciphers.find((cipher) => cipher.organizationId == null);
if (cipher != null) {
try {
await cipher.decrypt(userKey);
return true;
} catch (error) {
this.logService.error(
"[UserAsymmetricKeyRegeneration] User Symmetric Key validation error: " + error,
);
return false;
}
}
return false;
}
}

8
package-lock.json generated
View File

@ -24,7 +24,7 @@
"@angular/platform-browser": "17.3.12",
"@angular/platform-browser-dynamic": "17.3.12",
"@angular/router": "17.3.12",
"@bitwarden/sdk-internal": "0.2.0-main.3",
"@bitwarden/sdk-internal": "0.2.0-main.38",
"@electron/fuses": "1.8.0",
"@koa/multer": "3.0.2",
"@koa/router": "13.1.0",
@ -4298,9 +4298,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.3",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.3.tgz",
"integrity": "sha512-CYp98uaVMSFp6nr/QLw+Qw8ttnVtWark/bMpw59OhwMVhrCDKmpCgcR9G4oEdVO11IuFcYZieTBmtOEPhCpGaw==",
"version": "0.2.0-main.38",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.38.tgz",
"integrity": "sha512-bkN+BZC0YA4k0To8QiT33UTZX8peKDXud8Gzq3UHNPlU/vMSkP3Wn8q0GezzmYN3UNNIWXfreNCS0mJ+S51j/Q==",
"license": "GPL-3.0"
},
"node_modules/@bitwarden/vault": {

View File

@ -154,7 +154,7 @@
"@angular/platform-browser": "17.3.12",
"@angular/platform-browser-dynamic": "17.3.12",
"@angular/router": "17.3.12",
"@bitwarden/sdk-internal": "0.2.0-main.3",
"@bitwarden/sdk-internal": "0.2.0-main.38",
"@electron/fuses": "1.8.0",
"@koa/multer": "3.0.2",
"@koa/router": "13.1.0",