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

[PM-16603] Implement userkey rotation v2 (#12646)

* Implement key rotation v2

* Pass through masterpassword hint

* Properly split old and new code

* Mark legacy rotation as deprecated

* Throw when data is null

* Cleanup

* Add tests

* Fix build

* Update libs/key-management/src/key.service.spec.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update apps/web/src/app/auth/settings/change-password.component.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Add documentation

* Centralize loading logic

* Fix build

* Remove sharedlib from legacymigration component

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-03-24 20:41:21 +01:00
committed by GitHub
parent 8e62e0589d
commit 8c6a33d7b8
18 changed files with 642 additions and 19 deletions

View File

@@ -121,7 +121,7 @@
[(ngModel)]="masterPasswordHint"
/>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
<button type="submit" buttonType="primary" bitButton [loading]="loading">
{{ "changeMasterPassword" | i18n }}
</button>
</form>

View File

@@ -12,7 +12,9 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -35,11 +37,13 @@ export class ChangePasswordComponent
extends BaseChangePasswordComponent
implements OnInit, OnDestroy
{
loading = false;
rotateUserKey = false;
currentMasterPassword: string;
masterPasswordHint: string;
checkForBreaches = true;
characterMinimumMessage = "";
userkeyRotationV2 = false;
constructor(
i18nService: I18nService,
@@ -56,9 +60,10 @@ export class ChangePasswordComponent
private userVerificationService: UserVerificationService,
private keyRotationService: UserKeyRotationService,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
toastService: ToastService,
private configService: ConfigService,
) {
super(
i18nService,
@@ -75,6 +80,8 @@ export class ChangePasswordComponent
}
async ngOnInit() {
this.userkeyRotationV2 = await this.configService.getFeatureFlag(FeatureFlag.UserKeyRotationV2);
if (!(await this.userVerificationService.hasMasterPassword())) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -137,6 +144,130 @@ export class ChangePasswordComponent
}
async submit() {
if (this.userkeyRotationV2) {
this.loading = true;
await this.submitNew();
this.loading = false;
} else {
await this.submitOld();
}
}
async submitNew() {
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return;
}
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("hintEqualsPassword"),
});
return;
}
this.leakedPassword = false;
if (this.checkForBreaches) {
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0;
}
if (!(await this.strongPassword())) {
return;
}
try {
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
this.currentMasterPassword,
this.masterPassword,
user,
this.masterPasswordHint,
);
} else {
await this.updatePassword(this.masterPassword);
}
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
}
}
// todo: move this to a service
// https://bitwarden.atlassian.net/browse/PM-17108
private async updatePassword(newMasterPassword: string) {
const currentMasterPassword = this.currentMasterPassword;
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))),
);
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
const currentMasterKey = await this.keyService.makeMasterKey(
currentMasterPassword,
email,
kdfConfig,
);
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = await this.keyService.hashMasterKey(
this.currentMasterPassword,
currentMasterKey,
);
request.masterPasswordHint = this.masterPasswordHint;
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
try {
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
async submitOld() {
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
@@ -242,6 +373,6 @@ export class ChangePasswordComponent
private async updateKey() {
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(this.masterPassword, user);
}
}

View File

@@ -0,0 +1,10 @@
export class AccountKeysRequest {
// Other keys encrypted by the userkey
userKeyEncryptedAccountPrivateKey: string;
accountPublicKey: string;
constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) {
this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey;
this.accountPublicKey = accountPublicKey;
}
}

View File

@@ -0,0 +1,35 @@
import { Argon2KdfConfig, KdfConfig, KdfType } from "@bitwarden/key-management";
export class MasterPasswordUnlockDataRequest {
kdfType: KdfType = KdfType.PBKDF2_SHA256;
kdfIterations: number = 0;
kdfMemory?: number;
kdfParallelism?: number;
email: string;
masterKeyAuthenticationHash: string;
masterKeyEncryptedUserKey: string;
masterPasswordHint?: string;
constructor(
kdfConfig: KdfConfig,
email: string,
masterKeyAuthenticationHash: string,
masterKeyEncryptedUserKey: string,
masterPasswordHash?: string,
) {
this.kdfType = kdfConfig.kdfType;
this.kdfIterations = kdfConfig.iterations;
if (kdfConfig.kdfType === KdfType.Argon2id) {
this.kdfMemory = (kdfConfig as Argon2KdfConfig).memory;
this.kdfParallelism = (kdfConfig as Argon2KdfConfig).parallelism;
}
this.email = email;
this.masterKeyAuthenticationHash = masterKeyAuthenticationHash;
this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey;
this.masterPasswordHint = masterPasswordHash;
}
}

View File

@@ -0,0 +1,29 @@
import { AccountKeysRequest } from "./account-keys.request";
import { UnlockDataRequest } from "./unlock-data.request";
import { UserDataRequest as AccountDataRequest } from "./userdata.request";
export class RotateUserAccountKeysRequest {
constructor(
accountUnlockData: UnlockDataRequest,
accountKeys: AccountKeysRequest,
accountData: AccountDataRequest,
oldMasterKeyAuthenticationHash: string,
) {
this.accountUnlockData = accountUnlockData;
this.accountKeys = accountKeys;
this.accountData = accountData;
this.oldMasterKeyAuthenticationHash = oldMasterKeyAuthenticationHash;
}
// Authentication for the request
oldMasterKeyAuthenticationHash: string;
// All methods to get to the userkey
accountUnlockData: UnlockDataRequest;
// Other keys encrypted by the userkey
accountKeys: AccountKeysRequest;
// User vault data encrypted by the userkey
accountData: AccountDataRequest;
}

View File

@@ -0,0 +1,26 @@
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";
import { MasterPasswordUnlockDataRequest } from "./master-password-unlock-data.request";
export class UnlockDataRequest {
// All methods to get to the userkey
masterPasswordUnlockData: MasterPasswordUnlockDataRequest;
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[];
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[];
passkeyUnlockData: WebauthnRotateCredentialRequest[];
constructor(
masterPasswordUnlockData: MasterPasswordUnlockDataRequest,
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[],
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[],
passkeyUnlockData: WebauthnRotateCredentialRequest[],
) {
this.masterPasswordUnlockData = masterPasswordUnlockData;
this.emergencyAccessUnlockData = emergencyAccessUnlockData;
this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData;
this.passkeyUnlockData = passkeyUnlockData;
}
}

View File

@@ -0,0 +1,19 @@
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
export class UserDataRequest {
ciphers: CipherWithIdRequest[];
folders: FolderWithIdRequest[];
sends: SendWithIdRequest[];
constructor(
ciphers: CipherWithIdRequest[],
folders: FolderWithIdRequest[],
sends: SendWithIdRequest[],
) {
this.ciphers = ciphers;
this.folders = folders;
this.sends = sends;
}
}

View File

@@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
import { UpdateKeyRequest } from "./request/update-key.request";
@Injectable()
@@ -11,4 +12,14 @@ export class UserKeyRotationApiService {
postUserKeyUpdate(request: UpdateKeyRequest): Promise<any> {
return this.apiService.send("POST", "/accounts/key", request, true, false);
}
postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise<any> {
return this.apiService.send(
"POST",
"/accounts/key-management/rotate-user-account-keys",
request,
true,
false,
);
}
}

View File

@@ -6,21 +6,25 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
import { KeyService } from "@bitwarden/key-management";
import { ToastService } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -47,6 +51,9 @@ describe("KeyRotationService", () => {
let mockSyncService: MockProxy<SyncService>;
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
let mockLogService: MockProxy<LogService>;
let mockVaultTimeoutService: MockProxy<VaultTimeoutService>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
const mockUser = {
id: "mockUserId" as UserId,
@@ -70,6 +77,9 @@ describe("KeyRotationService", () => {
mockSyncService = mock<SyncService>();
mockWebauthnLoginAdminService = mock<WebauthnLoginAdminService>();
mockLogService = mock<LogService>();
mockVaultTimeoutService = mock<VaultTimeoutService>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
keyRotationService = new UserKeyRotationService(
mockUserVerificationService,
@@ -85,6 +95,9 @@ describe("KeyRotationService", () => {
mockSyncService,
mockWebauthnLoginAdminService,
mockLogService,
mockVaultTimeoutService,
mockToastService,
mockI18nService,
);
});
@@ -94,6 +107,7 @@ describe("KeyRotationService", () => {
describe("rotateUserKeyAndEncryptedData", () => {
let privateKey: BehaviorSubject<UserPrivateKey | null>;
let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
beforeEach(() => {
mockKeyService.makeUserKey.mockResolvedValue([
@@ -112,6 +126,8 @@ describe("KeyRotationService", () => {
// Mock user verification
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
masterKey: "mockMasterKey" as any,
kdfConfig: DEFAULT_KDF_CONFIG,
email: "mockEmail",
policyOptions: null,
});
@@ -122,6 +138,12 @@ describe("KeyRotationService", () => {
privateKey = new BehaviorSubject("mockPrivateKey" as any);
mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
keyPair = new BehaviorSubject({
privateKey: "mockPrivateKey",
publicKey: "mockPublicKey",
} as any);
mockKeyService.userEncryptionKeyPair$.mockReturnValue(keyPair);
// Mock ciphers
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
@@ -147,8 +169,8 @@ describe("KeyRotationService", () => {
mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
});
it("rotates the user key and encrypted data", async () => {
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
it("rotates the user key and encrypted data legacy", async () => {
await keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser);
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
@@ -162,9 +184,47 @@ describe("KeyRotationService", () => {
expect(arg.webauthnKeys.length).toBe(2);
});
it("rotates the user key and encrypted data", async () => {
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockNewMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
DEFAULT_KDF_CONFIG.kdfType,
);
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe(
DEFAULT_KDF_CONFIG.iterations,
);
expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey"));
expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData");
expect(arg.accountData.ciphers.length).toBe(2);
expect(arg.accountData.folders.length).toBe(2);
expect(arg.accountData.sends.length).toBe(2);
});
it("legacy throws if master password provided is falsey", async () => {
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("", mockUser),
).rejects.toThrow();
});
it("throws if master password provided is falsey", async () => {
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData("", "", mockUser),
).rejects.toThrow();
});
it("legacy throws if user key creation fails", async () => {
mockKeyService.makeUserKey.mockResolvedValueOnce([null, null]);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
@@ -175,15 +235,41 @@ describe("KeyRotationService", () => {
]);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if no private key is found", async () => {
privateKey.next(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("throws if no private key is found", async () => {
privateKey.next(null);
keyPair.next(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if master password is incorrect", async () => {
mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce(
new Error("Invalid master password"),
);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
@@ -193,15 +279,31 @@ describe("KeyRotationService", () => {
);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError"));
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
});

View File

@@ -7,7 +7,11 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
@@ -15,13 +19,19 @@ import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
import { EmergencyAccessService } from "../../auth/emergency-access";
import { AccountKeysRequest } from "./request/account-keys.request";
import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request";
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
import { UnlockDataRequest } from "./request/unlock-data.request";
import { UpdateKeyRequest } from "./request/update-key.request";
import { UserDataRequest } from "./request/userdata.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
@Injectable()
@@ -40,14 +50,180 @@ export class UserKeyRotationService {
private syncService: SyncService,
private webauthnLoginAdminService: WebauthnLoginAdminService,
private logService: LogService,
private vaultTimeoutService: VaultTimeoutService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
/**
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
* @param oldMasterPassword: The current master password
* @param newMasterPassword: The new master password
* @param user: The user account
* @param newMasterPasswordHint: The hint for the new master password
*/
async rotateUserKeyAndEncryptedData(masterPassword: string, user: Account): Promise<void> {
async rotateUserKeyMasterPasswordAndEncryptedData(
oldMasterPassword: string,
newMasterPassword: string,
user: Account,
newMasterPasswordHint?: string,
): Promise<void> {
this.logService.info("[Userkey rotation] Starting user key rotation...");
if (!newMasterPassword) {
this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
throw new Error("Invalid master password");
}
if ((await this.syncService.getLastSync()) === null) {
this.logService.info("[Userkey rotation] Client was never synced. Aborting!");
throw new Error(
"The local vault is de-synced and the keys cannot be rotated. Please log out and log back in to resolve this issue.",
);
}
const {
masterKey: oldMasterKey,
email,
kdfConfig,
} = await this.userVerificationService.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: oldMasterPassword,
},
user.id,
user.email,
);
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const [newUnencryptedUserKey, newMasterKeyEncryptedUserKey] =
await this.keyService.makeUserKey(newMasterKey);
if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) {
this.logService.info("[Userkey rotation] User key could not be created. Aborting!");
throw new Error("User key could not be created");
}
const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest(
kdfConfig,
email,
newMasterKeyAuthenticationHash,
newMasterKeyEncryptedUserKey.encryptedString!,
newMasterPasswordHint,
);
const keyPair = await firstValueFrom(this.keyService.userEncryptionKeyPair$(user.id));
if (keyPair == null) {
this.logService.info("[Userkey rotation] Key pair is null. Aborting!");
throw new Error("Key pair is null");
}
const { privateKey, publicKey } = keyPair;
const accountKeysRequest = new AccountKeysRequest(
(await this.encryptService.encrypt(privateKey, newUnencryptedUserKey)).encryptedString!,
Utils.fromBufferToB64(publicKey),
);
const originalUserKey = await firstValueFrom(this.keyService.userKey$(user.id));
if (originalUserKey == null) {
this.logService.info("[Userkey rotation] Userkey is null. Aborting!");
throw new Error("Userkey key is null");
}
const rotatedCiphers = await this.cipherService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const rotatedFolders = await this.folderService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const rotatedSends = await this.sendService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
if (rotatedCiphers == null || rotatedFolders == null || rotatedSends == null) {
this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!");
throw new Error("ciphers, folders, or sends are null");
}
const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
// Note: Reset password keys request model has user verification
// properties, but the rotation endpoint uses its own MP hash.
const organizationAccountRecoveryUnlockData = await this.resetPasswordService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
if (organizationAccountRecoveryUnlockData == null) {
this.logService.info(
"[Userkey rotation] Organization account recovery data is null. Aborting!",
);
throw new Error("Organization account recovery data is null");
}
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const unlockDataRequest = new UnlockDataRequest(
masterPasswordUnlockData,
emergencyAccessUnlockData,
organizationAccountRecoveryUnlockData,
passkeyUnlockData,
);
const request = new RotateUserAccountKeysRequest(
unlockDataRequest,
accountKeysRequest,
accountDataRequest,
await this.keyService.hashMasterKey(oldMasterPassword, oldMasterKey),
);
this.logService.info("[Userkey rotation] Posting user key rotation request to server");
await this.apiService.postUserKeyUpdateV2(request);
this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
// TODO PM-2199: Add device trust rotation support to the user key rotation endpoint
this.logService.info("[Userkey rotation] Rotating device trust...");
await this.deviceTrustService.rotateDevicesTrust(
user.id,
newUnencryptedUserKey,
newMasterKeyAuthenticationHash,
);
this.logService.info("[Userkey rotation] Device trust rotation completed");
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("rotationCompletedTitle"),
message: this.i18nService.t("rotationCompletedDesc"),
timeout: 15000,
});
// temporary until userkey can be better verified
await this.vaultTimeoutService.logOut();
}
/**
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
* @deprecated
*/
async rotateUserKeyAndEncryptedDataLegacy(masterPassword: string, user: Account): Promise<void> {
this.logService.info("[Userkey rotation] Starting legacy user key rotation...");
if (!masterPassword) {
this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
throw new Error("Invalid master password");
@@ -168,6 +344,7 @@ export class UserKeyRotationService {
this.logService.info("[Userkey rotation] Rotating device trust...");
await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash);
this.logService.info("[Userkey rotation] Device trust rotation completed");
await this.vaultTimeoutService.logOut();
}
private async encryptPrivateKey(

View File

@@ -64,7 +64,7 @@ export class MigrateFromLegacyEncryptionComponent {
try {
await this.syncService.fullSync(false, true);
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser);
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(masterPassword, activeUser);
this.toastService.showToast({
variant: "success",

View File

@@ -224,6 +224,8 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -282,6 +284,8 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: "MasterPasswordPolicyOptions",
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});

View File

@@ -237,7 +237,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
);
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
return { policyOptions, masterKey };
return { policyOptions, masterKey, kdfConfig, email };
}
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {

View File

@@ -1,3 +1,5 @@
import { KdfConfig } from "@bitwarden/key-management";
import { MasterKey } from "../../types/key";
import { VerificationType } from "../enums/verification-type";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
@@ -22,5 +24,7 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio
export type MasterPasswordVerificationResponse = {
masterKey: MasterKey;
kdfConfig: KdfConfig;
email: string;
policyOptions: MasterPasswordPolicyResponse | null;
};

View File

@@ -39,6 +39,7 @@ export enum FeatureFlag {
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
UserKeyRotationV2 = "userkey-rotation-v2",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption",
@@ -99,6 +100,7 @@ export const DefaultFeatureFlagValue = {
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,

View File

@@ -326,6 +326,17 @@ export abstract class KeyService {
*/
abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey | null>;
/**
* Gets an observable stream of the given users decrypted private key and public key, guaranteed to be consistent.
* Will emit null if the user doesn't have a userkey to decrypt the encrypted private key, or null if the user doesn't have a private key
* at all.
*
* @param userId The user id of the user to get the data for.
*/
abstract userEncryptionKeyPair$(
userId: UserId,
): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>;
/**
* Generates a fingerprint phrase for the user based on their public key
*

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { bufferCount, firstValueFrom, lastValueFrom, of, take, tap } from "rxjs";
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take, tap } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
@@ -802,4 +802,51 @@ describe("keyService", () => {
},
);
});
describe("userPrivateKey$", () => {
type SetupKeysParams = {
makeMasterKey: boolean;
makeUserKey: boolean;
};
function setupKeys({ makeMasterKey, makeUserKey }: SetupKeysParams): [UserKey, MasterKey] {
const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY);
const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey<MasterKey>(64) : null;
masterPasswordService.masterKeySubject.next(fakeMasterKey);
userKeyState.nextState(null);
const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey<UserKey>(64) : null;
userKeyState.nextState(fakeUserKey);
return [fakeUserKey, fakeMasterKey];
}
it("returns null when private key is null", async () => {
setupKeys({ makeMasterKey: false, makeUserKey: false });
keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId));
expect(key).toEqual(null);
});
it("returns null when private key is undefined", async () => {
setupKeys({ makeUserKey: true, makeMasterKey: false });
keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject(undefined));
const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId));
expect(key).toEqual(null);
});
it("returns keys when private key is defined", async () => {
setupKeys({ makeUserKey: false, makeMasterKey: true });
keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key"));
cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
Utils.fromUtf8ToArray("public key"),
);
const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId));
expect(key).toEqual({
privateKey: "private key",
publicKey: Utils.fromUtf8ToArray("public key"),
});
});
});
});

View File

@@ -895,6 +895,21 @@ export class DefaultKeyService implements KeyServiceAbstraction {
);
}
userEncryptionKeyPair$(
userId: UserId,
): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null> {
return this.userPrivateKey$(userId).pipe(
switchMap(async (privateKey) => {
if (privateKey == null) {
return null;
}
const publicKey = (await this.derivePublicKey(privateKey))!;
return { privateKey, publicKey };
}),
);
}
userEncryptedPrivateKey$(userId: UserId): Observable<EncryptedString | null> {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$;
}