1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 19:23:52 +00: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
20 changed files with 629 additions and 19 deletions

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;
}
}