1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

Implement key rotation v2

This commit is contained in:
Bernd Schoolmann
2025-01-28 15:26:10 +01:00
parent 582beaf706
commit d4caf6f8f5
17 changed files with 519 additions and 74 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,15 +12,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
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 { 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";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -37,11 +34,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,
@@ -60,9 +59,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,
@@ -81,6 +81,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
@@ -141,6 +143,7 @@ export class ChangePasswordComponent
}
async submit() {
this.loading = true;
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
@@ -150,6 +153,7 @@ export class ChangePasswordComponent
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("hintEqualsPassword"),
});
this.loading = false;
return;
}
@@ -158,46 +162,59 @@ export class ChangePasswordComponent
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0;
}
await super.submit();
}
if (!(await this.strongPassword())) {
this.loading = false;
return;
}
async setupSubmitActions() {
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
try {
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
const user = await firstValueFrom(this.accountService.activeAccount$);
if (this.userkeyRotationV2) {
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
this.currentMasterPassword,
this.masterPassword,
user,
);
} else {
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(
this.currentMasterPassword,
user,
);
}
} else {
await this.updatePassword(this.masterPassword);
}
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
message: e.message,
});
return false;
}
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
}
return super.setupSubmitActions();
this.loading = false;
}
async performSubmitActions(
newMasterPasswordHash: string,
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString],
) {
const masterKey = await this.keyService.makeMasterKey(
this.currentMasterPassword,
await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))),
await this.kdfConfigService.getKdfConfig(),
// 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 userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const newLocalKeyHash = await this.keyService.hashMasterKey(
this.masterPassword,
newMasterKey,
HashPurpose.LocalAuthorization,
const currentMasterKey = await this.keyService.makeMasterKey(
currentMasterPassword,
email,
kdfConfig,
);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
if (userKey == null) {
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: null,
@@ -206,33 +223,29 @@ export class ChangePasswordComponent
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,
masterKey,
currentMasterKey,
);
request.masterPasswordHint = this.masterPasswordHint;
request.newMasterPasswordHash = newMasterPasswordHash;
request.key = newUserKey[1].encryptedString;
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
try {
if (this.rotateUserKey) {
this.formPromise = this.apiService.postPassword(request).then(async () => {
// we need to save this for local masterkey verification during rotation
await this.masterPasswordService.setMasterKeyHash(newLocalKeyHash, userId as UserId);
await this.masterPasswordService.setMasterKey(newMasterKey, userId as UserId);
return this.updateKey();
});
} else {
this.formPromise = this.apiService.postPassword(request);
}
await this.formPromise;
await this.apiService.postPassword(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("logBackIn"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
} catch {
@@ -243,9 +256,4 @@ export class ChangePasswordComponent
});
}
}
private async updateKey() {
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyAndEncryptedData(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,31 @@
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;
constructor(
kdfConfig: KdfConfig,
email: string,
masterKeyAuthenticationHash: string,
masterKeyEncryptedUserKey: 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;
}
}

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

@@ -4,24 +4,31 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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 { 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 { DialogService, 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 "../core";
@@ -48,6 +55,13 @@ describe("KeyRotationService", () => {
let mockSyncService: MockProxy<SyncService>;
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
let mockLogService: MockProxy<LogService>;
let mockVaultTimeoutService: MockProxy<VaultTimeoutService>;
let mockeDialogService: MockProxy<DialogService>;
let mockFullApiService: MockProxy<ApiService>;
let mockTokenService: MockProxy<TokenService>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
let mockInternalMasterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
const mockUser = {
id: "mockUserId" as UserId,
@@ -71,6 +85,13 @@ describe("KeyRotationService", () => {
mockSyncService = mock<SyncService>();
mockWebauthnLoginAdminService = mock<WebauthnLoginAdminService>();
mockLogService = mock<LogService>();
mockVaultTimeoutService = mock<VaultTimeoutService>();
mockeDialogService = mock<DialogService>();
mockFullApiService = mock<ApiService>();
mockTokenService = mock<TokenService>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
mockInternalMasterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
keyRotationService = new UserKeyRotationService(
mockUserVerificationService,
@@ -86,6 +107,13 @@ describe("KeyRotationService", () => {
mockSyncService,
mockWebauthnLoginAdminService,
mockLogService,
mockVaultTimeoutService,
mockeDialogService,
mockFullApiService,
mockTokenService,
mockToastService,
mockI18nService,
mockInternalMasterPasswordService,
);
});
@@ -95,6 +123,7 @@ describe("KeyRotationService", () => {
describe("rotateUserKeyAndEncryptedData", () => {
let privateKey: BehaviorSubject<UserPrivateKey>;
let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
beforeEach(() => {
mockKeyService.makeUserKey.mockResolvedValue([
@@ -113,6 +142,8 @@ describe("KeyRotationService", () => {
// Mock user verification
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
masterKey: "mockMasterKey" as any,
kdfConfig: DEFAULT_KDF_CONFIG,
email: "mockEmail",
policyOptions: null,
});
@@ -123,6 +154,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);
@@ -148,8 +185,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];
@@ -163,9 +200,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();
});
@@ -173,15 +248,41 @@ describe("KeyRotationService", () => {
mockKeyService.makeUserKey.mockResolvedValueOnce([null, null]);
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();
});
@@ -191,15 +292,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

@@ -3,13 +3,20 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
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";
@@ -17,13 +24,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 { DialogService, 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()
@@ -42,14 +55,157 @@ export class UserKeyRotationService {
private syncService: SyncService,
private webauthnLoginAdminService: WebauthnLoginAdminService,
private logService: LogService,
private vaultTimeoutService: VaultTimeoutService,
private dialogService: DialogService,
private fullApiService: ApiService,
private tokenService: TokenService,
private toastService: ToastService,
private i18nService: I18nService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
/**
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
*/
async rotateUserKeyAndEncryptedData(masterPassword: string, user: Account): Promise<void> {
async rotateUserKeyMasterPasswordAndEncryptedData(
oldMasterPassword: string,
newMasterPassword: string,
user: Account,
): 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,
);
const { privateKey, publicKey } = await firstValueFrom(
this.keyService.userEncryptionKeyPair$(user.id),
);
const accountKeysRequest = new AccountKeysRequest(
(await this.encryptService.encrypt(privateKey, newUnencryptedUserKey)).encryptedString,
Utils.fromBufferToB64(publicKey),
);
const originalUserKey = await firstValueFrom(this.keyService.userKey$(user.id));
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,
);
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,
);
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)
*/
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");
@@ -167,6 +323,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

@@ -4,6 +4,7 @@ import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -21,7 +22,7 @@ import { UserKeyRotationService } from "../key-rotation/user-key-rotation.servic
// This component is used to migrate from the old encryption scheme to the new one.
@Component({
standalone: true,
imports: [SharedModule, UserKeyRotationModule],
imports: [SharedModule, JslibServicesModule, UserKeyRotationModule],
templateUrl: "migrate-legacy-encryption.component.html",
})
export class MigrateFromLegacyEncryptionComponent {
@@ -62,7 +63,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

@@ -226,6 +226,8 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -284,6 +286,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;
};

View File

@@ -34,6 +34,7 @@ export enum FeatureFlag {
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
UserKeyRotationV2 = "userkey-rotation-v2",
CipherKeyEncryption = "cipher-key-encryption",
PM11901_RefactorSelfHostingLicenseUploader = "PM-11901-refactor-self-hosting-license-uploader",
CriticalApps = "pm-14466-risk-insights-critical-application",
@@ -91,6 +92,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.UserKeyRotationV2]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM11901_RefactorSelfHostingLicenseUploader]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,

View File

@@ -332,6 +332,17 @@ export abstract class KeyService {
*/
abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey>;
/**
* 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
* @param fingerprintMaterial Fingerprint material

View File

@@ -911,6 +911,21 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey));
}
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> {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$;
}