mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[PM-21944] Split up userkey rotation v2 and add tests (#14900)
* Split up userkey rotation v2 and add tests * Fix eslint * Fix type errors * Fix tests * Clear up trusted key naming * Split up getNewAccountKeys * Add trim and lowercase * Replace user.email with masterKeySalt * Add wasTrustDenied to verifyTrust in key rotation service * Move testable userkey rotation service code to testable class * Fix build * Undo changes * Fix incorrect behavior on aborting key rotation and fix import * Fix tests * Make members of userkey rotation service protected * Fix type error * Cleanup and add injectable annotation * Fix tests * Update apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Remove v1 rotation request --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
@@ -1,25 +0,0 @@
|
|||||||
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
|
|
||||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
|
||||||
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";
|
|
||||||
|
|
||||||
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";
|
|
||||||
|
|
||||||
export class UpdateKeyRequest {
|
|
||||||
masterPasswordHash: string;
|
|
||||||
key: string;
|
|
||||||
privateKey: string;
|
|
||||||
ciphers: CipherWithIdRequest[] = [];
|
|
||||||
folders: FolderWithIdRequest[] = [];
|
|
||||||
sends: SendWithIdRequest[] = [];
|
|
||||||
emergencyAccessKeys: EmergencyAccessWithIdRequest[] = [];
|
|
||||||
resetPasswordKeys: OrganizationUserResetPasswordWithIdRequest[] = [];
|
|
||||||
webauthnKeys: WebauthnRotateCredentialRequest[] = [];
|
|
||||||
|
|
||||||
constructor(masterPasswordHash: string, key: string, privateKey: string) {
|
|
||||||
this.masterPasswordHash = masterPasswordHash;
|
|
||||||
this.key = key;
|
|
||||||
this.privateKey = privateKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,17 +3,12 @@ import { inject, Injectable } from "@angular/core";
|
|||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
|
||||||
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
|
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
|
||||||
import { UpdateKeyRequest } from "./request/update-key.request";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserKeyRotationApiService {
|
export class UserKeyRotationApiService {
|
||||||
readonly apiService = inject(ApiService);
|
readonly apiService = inject(ApiService);
|
||||||
|
|
||||||
postUserKeyUpdate(request: UpdateKeyRequest): Promise<any> {
|
postUserKeyUpdate(request: RotateUserAccountKeysRequest): Promise<any> {
|
||||||
return this.apiService.send("POST", "/accounts/key", request, true, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise<any> {
|
|
||||||
return this.apiService.send(
|
return this.apiService.send(
|
||||||
"POST",
|
"POST",
|
||||||
"/accounts/key-management/rotate-user-account-keys",
|
"/accounts/key-management/rotate-user-account-keys",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,27 @@
|
|||||||
import { Injectable } from "@angular/core";
|
import { Injectable } from "@angular/core";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, Observable } from "rxjs";
|
||||||
|
|
||||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
|
||||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
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 { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { UserKey } from "@bitwarden/common/types/key";
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { DialogService, ToastService } from "@bitwarden/components";
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||||
import {
|
import {
|
||||||
AccountRecoveryTrustComponent,
|
AccountRecoveryTrustComponent,
|
||||||
EmergencyAccessTrustComponent,
|
EmergencyAccessTrustComponent,
|
||||||
@@ -40,10 +40,16 @@ import { UnlockDataRequest } from "./request/unlock-data.request";
|
|||||||
import { UserDataRequest } from "./request/userdata.request";
|
import { UserDataRequest } from "./request/userdata.request";
|
||||||
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
||||||
|
|
||||||
@Injectable()
|
type MasterPasswordAuthenticationAndUnlockData = {
|
||||||
|
masterPassword: string;
|
||||||
|
masterKeySalt: string;
|
||||||
|
masterKeyKdfConfig: KdfConfig;
|
||||||
|
masterPasswordHint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: "root" })
|
||||||
export class UserKeyRotationService {
|
export class UserKeyRotationService {
|
||||||
constructor(
|
constructor(
|
||||||
private userVerificationService: UserVerificationService,
|
|
||||||
private apiService: UserKeyRotationApiService,
|
private apiService: UserKeyRotationApiService,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private folderService: FolderService,
|
private folderService: FolderService,
|
||||||
@@ -61,118 +67,345 @@ export class UserKeyRotationService {
|
|||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private cryptoFunctionService: CryptoFunctionService,
|
||||||
|
private kdfConfigService: KdfConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user key and re-encrypts all required data with the it.
|
* Creates a new user key and re-encrypts all required data with the it.
|
||||||
* @param oldMasterPassword: The current master password
|
* @param currentMasterPassword: The current master password
|
||||||
* @param newMasterPassword: The new master password
|
* @param newMasterPassword: The new master password
|
||||||
* @param user: The user account
|
* @param user: The user account
|
||||||
* @param newMasterPasswordHint: The hint for the new master password
|
* @param newMasterPasswordHint: The hint for the new master password
|
||||||
*/
|
*/
|
||||||
async rotateUserKeyMasterPasswordAndEncryptedData(
|
async rotateUserKeyMasterPasswordAndEncryptedData(
|
||||||
oldMasterPassword: string,
|
currentMasterPassword: string,
|
||||||
newMasterPassword: string,
|
newMasterPassword: string,
|
||||||
user: Account,
|
user: Account,
|
||||||
newMasterPasswordHint?: string,
|
newMasterPasswordHint?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logService.info("[Userkey rotation] Starting user key rotation...");
|
this.logService.info("[UserKey Rotation] Starting user key rotation...");
|
||||||
if (!newMasterPassword) {
|
|
||||||
this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
|
const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag(
|
||||||
throw new Error("Invalid master password");
|
FeatureFlag.EnrollAeadOnKeyRotation,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure all conditions match - e.g. account state is up to date
|
||||||
|
await this.ensureIsAllowedToRotateUserKey();
|
||||||
|
|
||||||
|
// First, the provided organizations and emergency access users need to be verified;
|
||||||
|
// this is currently done by providing the user a manual confirmation dialog.
|
||||||
|
const { wasTrustDenied, trustedOrganizationPublicKeys, trustedEmergencyAccessUserPublicKeys } =
|
||||||
|
await this.verifyTrust(user);
|
||||||
|
if (wasTrustDenied) {
|
||||||
|
this.logService.info("[Userkey rotation] Trust was denied by user. Aborting!");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read current cryptographic state / settings
|
||||||
|
const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow(
|
||||||
|
this.kdfConfigService.getKdfConfig$(user.id),
|
||||||
|
"KDF config",
|
||||||
|
))!;
|
||||||
|
// The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased.
|
||||||
|
const masterKeySalt = user.email.trim().toLowerCase();
|
||||||
|
const currentUserKey: UserKey = (await this.firstValueFromOrThrow(
|
||||||
|
this.keyService.userKey$(user.id),
|
||||||
|
"User key",
|
||||||
|
))!;
|
||||||
|
const currentUserKeyWrappedPrivateKey = new EncString(
|
||||||
|
(await this.firstValueFromOrThrow(
|
||||||
|
this.keyService.userEncryptedPrivateKey$(user.id),
|
||||||
|
"User encrypted private key",
|
||||||
|
))!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update account keys
|
||||||
|
// This creates at least a new user key, and possibly upgrades user encryption formats
|
||||||
|
let newUserKey: UserKey;
|
||||||
|
let wrappedPrivateKey: EncString;
|
||||||
|
let publicKey: string;
|
||||||
|
if (upgradeToV2FeatureFlagEnabled) {
|
||||||
|
this.logService.info("[Userkey rotation] Using v2 account keys");
|
||||||
|
const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2(
|
||||||
|
currentUserKey,
|
||||||
|
currentUserKeyWrappedPrivateKey,
|
||||||
|
);
|
||||||
|
newUserKey = userKey;
|
||||||
|
wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
|
||||||
|
publicKey = asymmetricEncryptionKeys.publicKey;
|
||||||
|
} else {
|
||||||
|
this.logService.info("[Userkey rotation] Using v1 account keys");
|
||||||
|
const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1(
|
||||||
|
currentUserKey,
|
||||||
|
currentUserKeyWrappedPrivateKey,
|
||||||
|
);
|
||||||
|
newUserKey = userKey;
|
||||||
|
wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
|
||||||
|
publicKey = asymmetricEncryptionKeys.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the key rotation request
|
||||||
|
const request = new RotateUserAccountKeysRequest(
|
||||||
|
await this.getAccountUnlockDataRequest(
|
||||||
|
user.id,
|
||||||
|
currentUserKey,
|
||||||
|
newUserKey,
|
||||||
|
{
|
||||||
|
masterPassword: newMasterPassword,
|
||||||
|
masterKeyKdfConfig,
|
||||||
|
masterKeySalt,
|
||||||
|
masterPasswordHint: newMasterPasswordHint,
|
||||||
|
} as MasterPasswordAuthenticationAndUnlockData,
|
||||||
|
trustedEmergencyAccessUserPublicKeys,
|
||||||
|
trustedOrganizationPublicKeys,
|
||||||
|
),
|
||||||
|
new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey),
|
||||||
|
await this.getAccountDataRequest(currentUserKey, newUserKey, user),
|
||||||
|
await this.makeServerMasterKeyAuthenticationHash(
|
||||||
|
currentMasterPassword,
|
||||||
|
masterKeyKdfConfig,
|
||||||
|
masterKeySalt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logService.info("[Userkey rotation] Posting user key rotation request to server");
|
||||||
|
await this.apiService.postUserKeyUpdate(request);
|
||||||
|
this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async ensureIsAllowedToRotateUserKey(): Promise<void> {
|
||||||
if ((await this.syncService.getLastSync()) === null) {
|
if ((await this.syncService.getLastSync()) === null) {
|
||||||
this.logService.info("[Userkey rotation] Client was never synced. Aborting!");
|
this.logService.info("[Userkey rotation] Client was never synced. Aborting!");
|
||||||
throw new Error(
|
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.",
|
"The local vault is de-synced and the keys cannot be rotated. Please log out and log back in to resolve this issue.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
|
|
||||||
const orgs = await this.resetPasswordService.getPublicKeys(user.id);
|
|
||||||
if (orgs.length > 0 || emergencyAccessGrantees.length > 0) {
|
|
||||||
const trustInfoDialog = KeyRotationTrustInfoComponent.open(this.dialogService, {
|
|
||||||
numberOfEmergencyAccessUsers: emergencyAccessGrantees.length,
|
|
||||||
orgName: orgs.length > 0 ? orgs[0].orgName : undefined,
|
|
||||||
});
|
|
||||||
const result = await firstValueFrom(trustInfoDialog.closed);
|
|
||||||
if (!result) {
|
|
||||||
this.logService.info("[Userkey rotation] Trust info dialog closed. Aborting!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
protected async getNewAccountKeysV1(
|
||||||
masterKey: oldMasterKey,
|
currentUserKey: UserKey,
|
||||||
email,
|
currentUserKeyWrappedPrivateKey: EncString,
|
||||||
kdfConfig,
|
): Promise<{
|
||||||
} = await this.userVerificationService.verifyUserByMasterPassword(
|
userKey: UserKey;
|
||||||
{
|
asymmetricEncryptionKeys: {
|
||||||
type: VerificationType.MasterPassword,
|
wrappedPrivateKey: EncString;
|
||||||
secret: oldMasterPassword,
|
publicKey: string;
|
||||||
},
|
};
|
||||||
user.id,
|
}> {
|
||||||
user.email,
|
// Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key.
|
||||||
|
// Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the
|
||||||
|
// creation of a new signing key pair.
|
||||||
|
const newUserKey = await this.makeNewUserKeyV1(currentUserKey);
|
||||||
|
|
||||||
|
// Re-encrypt the private key with the new user key
|
||||||
|
// Rotation of the private key is not supported yet
|
||||||
|
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||||
|
currentUserKeyWrappedPrivateKey,
|
||||||
|
currentUserKey,
|
||||||
);
|
);
|
||||||
|
const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey(
|
||||||
|
privateKey,
|
||||||
|
newUserKey,
|
||||||
|
);
|
||||||
|
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||||
|
|
||||||
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
|
return {
|
||||||
|
userKey: newUserKey,
|
||||||
let userKeyBytes: Uint8Array;
|
asymmetricEncryptionKeys: {
|
||||||
if (await this.configService.getFeatureFlag(FeatureFlag.EnrollAeadOnKeyRotation)) {
|
wrappedPrivateKey: newUserKeyWrappedPrivateKey,
|
||||||
userKeyBytes = PureCrypto.make_user_key_xchacha20_poly1305();
|
publicKey: Utils.fromBufferToB64(publicKey),
|
||||||
} else {
|
},
|
||||||
userKeyBytes = PureCrypto.make_user_key_aes256_cbc_hmac();
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async getNewAccountKeysV2(
|
||||||
|
currentUserKey: UserKey,
|
||||||
|
currentUserKeyWrappedPrivateKey: EncString,
|
||||||
|
): Promise<{
|
||||||
|
userKey: UserKey;
|
||||||
|
asymmetricEncryptionKeys: {
|
||||||
|
wrappedPrivateKey: EncString;
|
||||||
|
publicKey: string;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
throw new Error("User encryption v2 upgrade is not supported yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async createMasterPasswordUnlockDataRequest(
|
||||||
|
userKey: UserKey,
|
||||||
|
newUnlockData: MasterPasswordAuthenticationAndUnlockData,
|
||||||
|
): Promise<MasterPasswordUnlockDataRequest> {
|
||||||
|
// Decryption via stretched-masterkey-wrapped-userkey
|
||||||
const newMasterKeyEncryptedUserKey = new EncString(
|
const newMasterKeyEncryptedUserKey = new EncString(
|
||||||
PureCrypto.encrypt_user_key_with_master_password(
|
PureCrypto.encrypt_user_key_with_master_password(
|
||||||
userKeyBytes,
|
userKey.toEncoded(),
|
||||||
newMasterPassword,
|
newUnlockData.masterPassword,
|
||||||
email,
|
newUnlockData.masterKeySalt,
|
||||||
kdfConfig.toSdkConfig(),
|
newUnlockData.masterKeyKdfConfig.toSdkConfig(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const newUnencryptedUserKey = new SymmetricCryptoKey(userKeyBytes) as UserKey;
|
|
||||||
|
|
||||||
if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) {
|
const newMasterKeyAuthenticationHash = await this.makeServerMasterKeyAuthenticationHash(
|
||||||
this.logService.info("[Userkey rotation] User key could not be created. Aborting!");
|
newUnlockData.masterPassword,
|
||||||
throw new Error("User key could not be created");
|
newUnlockData.masterKeyKdfConfig,
|
||||||
}
|
newUnlockData.masterKeySalt,
|
||||||
|
|
||||||
const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey(
|
|
||||||
newMasterPassword,
|
|
||||||
newMasterKey,
|
|
||||||
HashPurpose.ServerAuthorization,
|
|
||||||
);
|
);
|
||||||
const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest(
|
|
||||||
kdfConfig,
|
return new MasterPasswordUnlockDataRequest(
|
||||||
email,
|
newUnlockData.masterKeyKdfConfig,
|
||||||
|
newUnlockData.masterKeySalt,
|
||||||
newMasterKeyAuthenticationHash,
|
newMasterKeyAuthenticationHash,
|
||||||
newMasterKeyEncryptedUserKey.encryptedString!,
|
newMasterKeyEncryptedUserKey.encryptedString!,
|
||||||
newMasterPasswordHint,
|
newUnlockData.masterPasswordHint,
|
||||||
);
|
);
|
||||||
|
|
||||||
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.wrapDecapsulationKey(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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async getAccountUnlockDataRequest(
|
||||||
|
userId: UserId,
|
||||||
|
currentUserKey: UserKey,
|
||||||
|
newUserKey: UserKey,
|
||||||
|
masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData,
|
||||||
|
trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
|
||||||
|
trustedOrganizationPublicKeys: Uint8Array[],
|
||||||
|
): Promise<UnlockDataRequest> {
|
||||||
|
// To ensure access; all unlock methods need to be updated and provided the new user key.
|
||||||
|
// User unlock methods
|
||||||
|
let masterPasswordUnlockData: MasterPasswordUnlockDataRequest;
|
||||||
|
if (this.isUserWithMasterPassword(userId)) {
|
||||||
|
masterPasswordUnlockData = await this.createMasterPasswordUnlockDataRequest(
|
||||||
|
newUserKey,
|
||||||
|
masterPasswordAuthenticationAndUnlockData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
|
||||||
|
currentUserKey,
|
||||||
|
newUserKey,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
|
||||||
|
currentUserKey,
|
||||||
|
newUserKey,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unlock methods that share to a different user / group
|
||||||
|
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
|
||||||
|
newUserKey,
|
||||||
|
trustedEmergencyAccessGranteesPublicKeys,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
|
||||||
|
newUserKey,
|
||||||
|
trustedOrganizationPublicKeys,
|
||||||
|
userId,
|
||||||
|
))!;
|
||||||
|
|
||||||
|
return new UnlockDataRequest(
|
||||||
|
masterPasswordUnlockData!,
|
||||||
|
emergencyAccessUnlockData,
|
||||||
|
organizationAccountRecoveryUnlockData,
|
||||||
|
passkeyUnlockData,
|
||||||
|
trustedDeviceUnlockData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async verifyTrust(user: Account): Promise<{
|
||||||
|
wasTrustDenied: boolean;
|
||||||
|
trustedOrganizationPublicKeys: Uint8Array[];
|
||||||
|
trustedEmergencyAccessUserPublicKeys: Uint8Array[];
|
||||||
|
}> {
|
||||||
|
// Since currently the joined organizations and emergency access grantees are
|
||||||
|
// not signed, manual trust prompts are required, to verify that the server
|
||||||
|
// does not inject public keys here.
|
||||||
|
//
|
||||||
|
// Once signing is implemented, this is the place to also sign the keys and
|
||||||
|
// upload the signed trust claims.
|
||||||
|
//
|
||||||
|
// The flow works in 3 steps:
|
||||||
|
// 1. Prepare the user by showing them a dialog telling them they'll be asked
|
||||||
|
// to verify the trust of their organizations and emergency access users.
|
||||||
|
// 2. Show the user a dialog for each organization and ask them to verify the trust.
|
||||||
|
// 3. Show the user a dialog for each emergency access user and ask them to verify the trust.
|
||||||
|
|
||||||
|
this.logService.info("[Userkey rotation] Verifying trust...");
|
||||||
|
const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
|
||||||
|
const organizations = await this.resetPasswordService.getPublicKeys(user.id);
|
||||||
|
|
||||||
|
if (organizations.length > 0 || emergencyAccessGrantees.length > 0) {
|
||||||
|
const trustInfoDialog = KeyRotationTrustInfoComponent.open(this.dialogService, {
|
||||||
|
numberOfEmergencyAccessUsers: emergencyAccessGrantees.length,
|
||||||
|
orgName: organizations.length > 0 ? organizations[0].orgName : undefined,
|
||||||
|
});
|
||||||
|
if (!(await firstValueFrom(trustInfoDialog.closed))) {
|
||||||
|
return {
|
||||||
|
wasTrustDenied: true,
|
||||||
|
trustedOrganizationPublicKeys: [],
|
||||||
|
trustedEmergencyAccessUserPublicKeys: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const organization of organizations) {
|
||||||
|
const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
|
||||||
|
name: organization.orgName,
|
||||||
|
orgId: organization.orgId,
|
||||||
|
publicKey: organization.publicKey,
|
||||||
|
});
|
||||||
|
if (!(await firstValueFrom(dialogRef.closed))) {
|
||||||
|
return {
|
||||||
|
wasTrustDenied: true,
|
||||||
|
trustedOrganizationPublicKeys: [],
|
||||||
|
trustedEmergencyAccessUserPublicKeys: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const details of emergencyAccessGrantees) {
|
||||||
|
const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
|
||||||
|
name: details.name,
|
||||||
|
userId: details.granteeId,
|
||||||
|
publicKey: details.publicKey,
|
||||||
|
});
|
||||||
|
if (!(await firstValueFrom(dialogRef.closed))) {
|
||||||
|
return {
|
||||||
|
wasTrustDenied: true,
|
||||||
|
trustedOrganizationPublicKeys: [],
|
||||||
|
trustedEmergencyAccessUserPublicKeys: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logService.info(
|
||||||
|
"[Userkey rotation] Trust verified for all organizations and emergency access users",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wasTrustDenied: false,
|
||||||
|
trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey),
|
||||||
|
trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAccountDataRequest(
|
||||||
|
originalUserKey: UserKey,
|
||||||
|
newUnencryptedUserKey: UserKey,
|
||||||
|
user: Account,
|
||||||
|
): Promise<UserDataRequest> {
|
||||||
|
// Account data is any data owned by the user; this is folders, ciphers (and their attachments), and sends.
|
||||||
|
|
||||||
|
// Currently, ciphers, folders and sends are directly encrypted with the user key. This means
|
||||||
|
// that they need to be re-encrypted and re-uploaded. In the future, content-encryption keys
|
||||||
|
// (such as cipher keys) will make it so only re-encrypted keys are required.
|
||||||
const rotatedCiphers = await this.cipherService.getRotatedData(
|
const rotatedCiphers = await this.cipherService.getRotatedData(
|
||||||
originalUserKey,
|
originalUserKey,
|
||||||
newUnencryptedUserKey,
|
newUnencryptedUserKey,
|
||||||
@@ -192,111 +425,102 @@ export class UserKeyRotationService {
|
|||||||
this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!");
|
this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!");
|
||||||
throw new Error("ciphers, folders, or sends are null");
|
throw new Error("ciphers, folders, or sends are null");
|
||||||
}
|
}
|
||||||
const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
|
return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
|
||||||
|
}
|
||||||
|
|
||||||
for (const details of emergencyAccessGrantees) {
|
protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise<UserKey> {
|
||||||
this.logService.info("[Userkey rotation] Emergency access grantee: " + details.name);
|
// The user's account format is determined by the user key.
|
||||||
|
// Being tied to the userkey ensures an all-or-nothing approach. A compromised
|
||||||
|
// server cannot downgrade to a previous format (no signing keys) without
|
||||||
|
// completely making the account unusable.
|
||||||
|
//
|
||||||
|
// V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
|
||||||
|
// This format is unsupported, and not secure; It is being forced migrated, and being removed
|
||||||
|
// V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
|
||||||
|
// This format is still supported, but may be migrated in the future
|
||||||
|
// V2: XChaCha20-Poly1305 userkey, signing key, account security version
|
||||||
|
// This is the new, modern format.
|
||||||
|
if (this.isV1User(oldUserKey)) {
|
||||||
this.logService.info(
|
this.logService.info(
|
||||||
"[Userkey rotation] Emergency access grantee fingerprint: " +
|
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading",
|
||||||
(await this.keyService.getFingerprint(details.granteeId, details.publicKey)).join("-"),
|
|
||||||
);
|
);
|
||||||
|
return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey;
|
||||||
const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
|
|
||||||
name: details.name,
|
|
||||||
userId: details.granteeId,
|
|
||||||
publicKey: details.publicKey,
|
|
||||||
});
|
|
||||||
const result = await firstValueFrom(dialogRef.closed);
|
|
||||||
if (result === true) {
|
|
||||||
this.logService.info("[Userkey rotation] Emergency access grantee confirmed");
|
|
||||||
} else {
|
} else {
|
||||||
this.logService.info("[Userkey rotation] Emergency access grantee not confirmed");
|
// If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account.
|
||||||
return;
|
this.logService.info(
|
||||||
|
"[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..",
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
"User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
|
|
||||||
|
|
||||||
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
|
protected async makeNewUserKeyV2(
|
||||||
newUnencryptedUserKey,
|
oldUserKey: UserKey,
|
||||||
trustedUserPublicKeys,
|
): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
|
||||||
user.id,
|
// The user's account format is determined by the user key.
|
||||||
);
|
// Being tied to the userkey ensures an all-or-nothing approach. A compromised
|
||||||
|
// server cannot downgrade to a previous format (no signing keys) without
|
||||||
for (const organization of orgs) {
|
// completely making the account unusable.
|
||||||
|
//
|
||||||
|
// V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
|
||||||
|
// This format is unsupported, and not secure; It is being forced migrated, and being removed
|
||||||
|
// V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
|
||||||
|
// This format is still supported, but may be migrated in the future
|
||||||
|
// V2: XChaCha20-Poly1305 userkey, signing key, account security version
|
||||||
|
// This is the new, modern format.
|
||||||
|
const newUserKey: UserKey = new SymmetricCryptoKey(
|
||||||
|
PureCrypto.make_user_key_xchacha20_poly1305(),
|
||||||
|
) as UserKey;
|
||||||
|
const isUpgrading = this.isV1User(oldUserKey);
|
||||||
|
if (isUpgrading) {
|
||||||
this.logService.info(
|
this.logService.info(
|
||||||
"[Userkey rotation] Reset password organization: " + organization.orgName,
|
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305",
|
||||||
);
|
);
|
||||||
this.logService.info(
|
|
||||||
"[Userkey rotation] Trusted organization public key: " + organization.publicKey,
|
|
||||||
);
|
|
||||||
const fingerprint = await this.keyService.getFingerprint(
|
|
||||||
organization.orgId,
|
|
||||||
organization.publicKey,
|
|
||||||
);
|
|
||||||
this.logService.info(
|
|
||||||
"[Userkey rotation] Trusted organization fingerprint: " + fingerprint.join("-"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
|
|
||||||
name: organization.orgName,
|
|
||||||
orgId: organization.orgId,
|
|
||||||
publicKey: organization.publicKey,
|
|
||||||
});
|
|
||||||
const result = await firstValueFrom(dialogRef.closed);
|
|
||||||
if (result === true) {
|
|
||||||
this.logService.info("[Userkey rotation] Organization trusted");
|
|
||||||
} else {
|
} else {
|
||||||
this.logService.info("[Userkey rotation] Organization not trusted");
|
this.logService.info(
|
||||||
return;
|
"[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return { isUpgrading, newUserKey };
|
||||||
}
|
}
|
||||||
const trustedOrgPublicKeys = orgs.map((d) => d.publicKey);
|
|
||||||
// Note: Reset password keys request model has user verification
|
/**
|
||||||
// properties, but the rotation endpoint uses its own MP hash.
|
* A V1 user has no signing key, and uses AES256-CBC-HMAC.
|
||||||
const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
|
* A V2 user has a signing key, and uses XChaCha20-Poly1305.
|
||||||
newUnencryptedUserKey,
|
*/
|
||||||
trustedOrgPublicKeys,
|
protected isV1User(userKey: UserKey): boolean {
|
||||||
user.id,
|
return userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64;
|
||||||
))!;
|
}
|
||||||
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
|
|
||||||
originalUserKey,
|
protected isUserWithMasterPassword(id: UserId): boolean {
|
||||||
newUnencryptedUserKey,
|
// Currently, key rotation can only be activated when the user has a master password.
|
||||||
user.id,
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async makeServerMasterKeyAuthenticationHash(
|
||||||
|
masterPassword: string,
|
||||||
|
masterKeyKdfConfig: KdfConfig,
|
||||||
|
masterKeySalt: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const masterKey = await this.keyService.makeMasterKey(
|
||||||
|
masterPassword,
|
||||||
|
masterKeySalt,
|
||||||
|
masterKeyKdfConfig,
|
||||||
);
|
);
|
||||||
|
return this.keyService.hashMasterKey(
|
||||||
const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
|
masterPassword,
|
||||||
originalUserKey,
|
masterKey,
|
||||||
newUnencryptedUserKey,
|
HashPurpose.ServerAuthorization,
|
||||||
user.id,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const unlockDataRequest = new UnlockDataRequest(
|
async firstValueFromOrThrow<T>(value: Observable<T>, name: string): Promise<T> {
|
||||||
masterPasswordUnlockData,
|
const result = await firstValueFrom(value);
|
||||||
emergencyAccessUnlockData,
|
if (result == null) {
|
||||||
organizationAccountRecoveryUnlockData,
|
throw new Error(`Failed to get ${name}`);
|
||||||
passkeyUnlockData,
|
}
|
||||||
trustedDeviceUnlockData,
|
return result;
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
|
import { OtherDeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
|
||||||
|
|
||||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||||
@@ -61,5 +61,5 @@ export abstract class DeviceTrustServiceAbstraction {
|
|||||||
oldUserKey: UserKey,
|
oldUserKey: UserKey,
|
||||||
newUserKey: UserKey,
|
newUserKey: UserKey,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
) => Promise<DeviceKeysUpdateRequest[]>;
|
) => Promise<OtherDeviceKeysUpdateRequest[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
|||||||
oldUserKey: UserKey,
|
oldUserKey: UserKey,
|
||||||
newUserKey: UserKey,
|
newUserKey: UserKey,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
): Promise<DeviceKeysUpdateRequest[]> {
|
): Promise<OtherDeviceKeysUpdateRequest[]> {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("UserId is required. Cannot get rotated data.");
|
throw new Error("UserId is required. Cannot get rotated data.");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user