1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 19:53:59 +00:00

Merge branch 'km/move-clientkeyhalf-to-platform-impl' into km/simplify-linux-biometrics

This commit is contained in:
Bernd Schoolmann
2025-06-11 14:12:22 +02:00
committed by GitHub
20 changed files with 1292 additions and 603 deletions

View File

@@ -437,7 +437,7 @@ export default class MainBackground {
constructor() {
// Services
const lockedCallback = async (userId?: string) => {
const lockedCallback = async (userId: UserId) => {
await this.refreshBadge();
await this.refreshMenu(true);
if (this.systemService != null) {

View File

@@ -31,7 +31,11 @@
<a bitMenuItem (click)="clone()" *ngIf="canClone$ | async">
{{ "clone" | i18n }}
</a>
<a bitMenuItem *ngIf="hasOrganizations" (click)="conditionallyNavigateToAssignCollections()">
<a
bitMenuItem
*ngIf="canAssignCollections$ | async"
(click)="conditionallyNavigateToAssignCollections()"
>
{{ "assignToCollections" | i18n }}
</a>
</ng-container>

View File

@@ -1,11 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input, OnInit } from "@angular/core";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -32,7 +33,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
templateUrl: "./item-more-options.component.html",
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
})
export class ItemMoreOptionsComponent implements OnInit {
export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherView>(undefined);
@Input({
@@ -71,8 +72,21 @@ export class ItemMoreOptionsComponent implements OnInit {
switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)),
);
/** Boolean dependent on the current user having access to an organization */
protected hasOrganizations = false;
/** Observable Boolean dependent on the current user having access to an organization and editable collections */
protected canAssignCollections$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => {
return combineLatest([
this.organizationService.hasOrganizations(userId),
this.collectionService.decryptedCollections$,
]).pipe(
map(([hasOrgs, collections]) => {
const canEditCollections = collections.some((c) => !c.readOnly);
return hasOrgs && canEditCollections;
}),
);
}),
);
constructor(
private cipherService: CipherService,
@@ -85,13 +99,9 @@ export class ItemMoreOptionsComponent implements OnInit {
private accountService: AccountService,
private organizationService: OrganizationService,
private cipherAuthorizationService: CipherAuthorizationService,
private collectionService: CollectionService,
) {}
async ngOnInit(): Promise<void> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.hasOrganizations = await firstValueFrom(this.organizationService.hasOrganizations(userId));
}
get canEdit() {
return this.cipher.edit;
}

View File

@@ -715,8 +715,8 @@ export class ServiceContainer {
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
const lockedCallback = async (userId?: string) =>
await this.keyService.clearStoredUserKey(KeySuffixOptions.Auto);
const lockedCallback = async (userId: UserId) =>
await this.keyService.clearStoredUserKey(KeySuffixOptions.Auto, userId);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);

View File

@@ -55,7 +55,7 @@ export class ElectronKeyService extends DefaultKeyService {
return super.hasUserKeyStored(keySuffix, userId);
}
override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId: UserId): Promise<void> {
await super.clearStoredUserKey(keySuffix, userId);
}

View File

@@ -788,7 +788,13 @@
<button
type="button"
(click)="share()"
*ngIf="editMode && cipher && !cipher.organizationId && !cloneMode"
*ngIf="
editMode &&
cipher &&
!cipher.organizationId &&
!cloneMode &&
writeableCollections.length > 0
"
>
{{ "move" | i18n }}
</button>

View File

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

View File

@@ -3,17 +3,12 @@ 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()
export class UserKeyRotationApiService {
readonly apiService = inject(ApiService);
postUserKeyUpdate(request: UpdateKeyRequest): Promise<any> {
return this.apiService.send("POST", "/accounts/key", request, true, false);
}
postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise<any> {
postUserKeyUpdate(request: RotateUserAccountKeysRequest): Promise<any> {
return this.apiService.send(
"POST",
"/accounts/key-management/rotate-user-account-keys",

View File

@@ -1,27 +1,27 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, Observable } from "rxjs";
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 { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.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 { 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 { HashPurpose } from "@bitwarden/common/platform/enums";
import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums";
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 { 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 { 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 { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
@@ -40,10 +40,16 @@ import { UnlockDataRequest } from "./request/unlock-data.request";
import { UserDataRequest } from "./request/userdata.request";
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 {
constructor(
private userVerificationService: UserVerificationService,
private apiService: UserKeyRotationApiService,
private cipherService: CipherService,
private folderService: FolderService,
@@ -61,118 +67,345 @@ export class UserKeyRotationService {
private i18nService: I18nService,
private dialogService: DialogService,
private configService: ConfigService,
private cryptoFunctionService: CryptoFunctionService,
private kdfConfigService: KdfConfigService,
) {}
/**
* 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 user: The user account
* @param newMasterPasswordHint: The hint for the new master password
*/
async rotateUserKeyMasterPasswordAndEncryptedData(
oldMasterPassword: string,
currentMasterPassword: 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");
this.logService.info("[UserKey Rotation] Starting user key rotation...");
const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag(
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) {
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.",
);
}
}
protected async getNewAccountKeysV1(
currentUserKey: UserKey,
currentUserKeyWrappedPrivateKey: EncString,
): Promise<{
userKey: UserKey;
asymmetricEncryptionKeys: {
wrappedPrivateKey: EncString;
publicKey: string;
};
}> {
// 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);
return {
userKey: newUserKey,
asymmetricEncryptionKeys: {
wrappedPrivateKey: newUserKeyWrappedPrivateKey,
publicKey: Utils.fromBufferToB64(publicKey),
},
};
}
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(
PureCrypto.encrypt_user_key_with_master_password(
userKey.toEncoded(),
newUnlockData.masterPassword,
newUnlockData.masterKeySalt,
newUnlockData.masterKeyKdfConfig.toSdkConfig(),
),
);
const newMasterKeyAuthenticationHash = await this.makeServerMasterKeyAuthenticationHash(
newUnlockData.masterPassword,
newUnlockData.masterKeyKdfConfig,
newUnlockData.masterKeySalt,
);
return new MasterPasswordUnlockDataRequest(
newUnlockData.masterKeyKdfConfig,
newUnlockData.masterKeySalt,
newMasterKeyAuthenticationHash,
newMasterKeyEncryptedUserKey.encryptedString!,
newUnlockData.masterPasswordHint,
);
}
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 orgs = await this.resetPasswordService.getPublicKeys(user.id);
if (orgs.length > 0 || emergencyAccessGrantees.length > 0) {
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: orgs.length > 0 ? orgs[0].orgName : undefined,
orgName: organizations.length > 0 ? organizations[0].orgName : undefined,
});
const result = await firstValueFrom(trustInfoDialog.closed);
if (!result) {
this.logService.info("[Userkey rotation] Trust info dialog closed. Aborting!");
return;
if (!(await firstValueFrom(trustInfoDialog.closed))) {
return {
wasTrustDenied: true,
trustedOrganizationPublicKeys: [],
trustedEmergencyAccessUserPublicKeys: [],
};
}
}
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);
let userKeyBytes: Uint8Array;
if (await this.configService.getFeatureFlag(FeatureFlag.EnrollAeadOnKeyRotation)) {
userKeyBytes = PureCrypto.make_user_key_xchacha20_poly1305();
} else {
userKeyBytes = PureCrypto.make_user_key_aes256_cbc_hmac();
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: [],
};
}
}
const newMasterKeyEncryptedUserKey = new EncString(
PureCrypto.encrypt_user_key_with_master_password(
userKeyBytes,
newMasterPassword,
email,
kdfConfig.toSdkConfig(),
),
);
const newUnencryptedUserKey = new SymmetricCryptoKey(userKeyBytes) as UserKey;
if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) {
this.logService.info("[Userkey rotation] User key could not be created. Aborting!");
throw new Error("User key could not be created");
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: [],
};
}
}
const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest(
kdfConfig,
email,
newMasterKeyAuthenticationHash,
newMasterKeyEncryptedUserKey.encryptedString!,
newMasterPasswordHint,
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),
};
}
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 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(
originalUserKey,
newUnencryptedUserKey,
@@ -192,111 +425,102 @@ export class UserKeyRotationService {
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);
return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
}
for (const details of emergencyAccessGrantees) {
this.logService.info("[Userkey rotation] Emergency access grantee: " + details.name);
protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise<UserKey> {
// 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(
"[Userkey rotation] Emergency access grantee fingerprint: " +
(await this.keyService.getFingerprint(details.granteeId, details.publicKey)).join("-"),
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading",
);
return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey;
} else {
// 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.
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 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 {
this.logService.info("[Userkey rotation] Emergency access grantee not confirmed");
return;
}
}
const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
}
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
newUnencryptedUserKey,
trustedUserPublicKeys,
user.id,
);
for (const organization of orgs) {
protected async makeNewUserKeyV2(
oldUserKey: UserKey,
): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
// 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.
const newUserKey: UserKey = new SymmetricCryptoKey(
PureCrypto.make_user_key_xchacha20_poly1305(),
) as UserKey;
const isUpgrading = this.isV1User(oldUserKey);
if (isUpgrading) {
this.logService.info(
"[Userkey rotation] Reset password organization: " + organization.orgName,
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305",
);
} else {
this.logService.info(
"[Userkey rotation] Trusted organization public key: " + organization.publicKey,
"[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed",
);
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 {
this.logService.info("[Userkey rotation] Organization not trusted");
return;
}
}
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.
const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
newUnencryptedUserKey,
trustedOrgPublicKeys,
user.id,
))!;
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
return { isUpgrading, newUserKey };
}
/**
* A V1 user has no signing key, and uses AES256-CBC-HMAC.
* A V2 user has a signing key, and uses XChaCha20-Poly1305.
*/
protected isV1User(userKey: UserKey): boolean {
return userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64;
}
protected isUserWithMasterPassword(id: UserId): boolean {
// Currently, key rotation can only be activated when the user has a master password.
return true;
}
protected async makeServerMasterKeyAuthenticationHash(
masterPassword: string,
masterKeyKdfConfig: KdfConfig,
masterKeySalt: string,
): Promise<string> {
const masterKey = await this.keyService.makeMasterKey(
masterPassword,
masterKeySalt,
masterKeyKdfConfig,
);
const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
return this.keyService.hashMasterKey(
masterPassword,
masterKey,
HashPurpose.ServerAuthorization,
);
}
const unlockDataRequest = new UnlockDataRequest(
masterPasswordUnlockData,
emergencyAccessUnlockData,
organizationAccountRecoveryUnlockData,
passkeyUnlockData,
trustedDeviceUnlockData,
);
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();
async firstValueFromOrThrow<T>(value: Observable<T>, name: string): Promise<T> {
const result = await firstValueFrom(value);
if (result == null) {
throw new Error(`Failed to get ${name}`);
}
return result;
}
}

View File

@@ -22,7 +22,6 @@ import { SmLandingApiService } from "./sm-landing-api.service";
@Component({
selector: "app-request-sm-access",
standalone: true,
templateUrl: "request-sm-access.component.html",
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, OssModule],
})

View File

@@ -14,7 +14,6 @@ import { SharedModule } from "../../shared/shared.module";
@Component({
selector: "app-sm-landing",
standalone: true,
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule],
templateUrl: "sm-landing.component.html",
})

View File

@@ -298,8 +298,11 @@ export class VaultItemsComponent {
protected canAssignCollections(cipher: CipherView) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
const editableCollections = this.allCollections.filter((c) => !c.readOnly);
return (
(organization?.canEditAllCiphers && this.viewingOrgVault) || cipher.canAssignToCollections
(organization?.canEditAllCiphers && this.viewingOrgVault) ||
(cipher.canAssignToCollections && editableCollections.length > 0)
);
}

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
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 { EncString } from "../../../platform/models/domain/enc-string";
@@ -61,5 +61,5 @@ export abstract class DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
) => Promise<DeviceKeysUpdateRequest[]>;
) => Promise<OtherDeviceKeysUpdateRequest[]>;
}

View File

@@ -200,7 +200,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<DeviceKeysUpdateRequest[]> {
): Promise<OtherDeviceKeysUpdateRequest[]> {
if (!userId) {
throw new Error("UserId is required. Cannot get rotated data.");
}

View File

@@ -417,16 +417,12 @@ describe("VaultTimeoutService", () => {
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call locked callback if no user passed into lock", async () => {
it("should call locked callback with the locking user if no userID is passed in.", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(lockedCallback).toHaveBeenCalledWith(undefined);
expect(lockedCallback).toHaveBeenCalledWith("user1");
});
it("should call state event runner with user passed into lock", async () => {

View File

@@ -49,7 +49,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
private lockedCallback: (userId?: string) => Promise<void> = null,
private lockedCallback: (userId: UserId) => Promise<void> = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
userId?: string,
@@ -166,7 +166,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) {
await this.lockedCallback(userId);
await this.lockedCallback(lockingUserId);
}
}

View File

@@ -167,8 +167,9 @@ export abstract class KeyService {
* Clears the user's stored version of the user key
* @param keySuffix The desired version of the key to clear
* @param userId The desired user
* @throws Error when userId is null or undefined.
*/
abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId: string): Promise<void>;
/**
* Stores the master key encrypted user key
* @throws Error when userId is null and there is no active user.

View File

@@ -410,6 +410,45 @@ describe("keyService", () => {
});
});
describe("clearStoredUserKey", () => {
describe("input validation", () => {
const invalidUserIdTestCases = [
{ keySuffix: KeySuffixOptions.Auto, userId: null as unknown as UserId },
{ keySuffix: KeySuffixOptions.Auto, userId: undefined as unknown as UserId },
{ keySuffix: KeySuffixOptions.Pin, userId: null as unknown as UserId },
{ keySuffix: KeySuffixOptions.Pin, userId: undefined as unknown as UserId },
];
test.each(invalidUserIdTestCases)(
"throws when keySuffix is $keySuffix and userId is $userId",
async ({ keySuffix, userId }) => {
await expect(keyService.clearStoredUserKey(keySuffix, userId)).rejects.toThrow(
"UserId is required",
);
},
);
});
describe("with Auto key suffix", () => {
it("UserKeyAutoUnlock is cleared and pin keys are not cleared", async () => {
await keyService.clearStoredUserKey(KeySuffixOptions.Auto, mockUserId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, {
userId: mockUserId,
});
expect(pinService.clearPinKeyEncryptedUserKeyEphemeral).not.toHaveBeenCalled();
});
});
describe("with PIN key suffix", () => {
it("pin keys are cleared and user key auto unlock not", async () => {
await keyService.clearStoredUserKey(KeySuffixOptions.Pin, mockUserId);
expect(stateService.setUserKeyAutoUnlock).not.toHaveBeenCalled();
expect(pinService.clearPinKeyEncryptedUserKeyEphemeral).toHaveBeenCalledWith(mockUserId);
});
});
});
describe("clearKeys", () => {
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userId is %s",

View File

@@ -250,16 +250,16 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.clearAllStoredUserKeys(userId);
}
async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
if (keySuffix === KeySuffixOptions.Auto) {
// 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
this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
async clearStoredUserKey(keySuffix: KeySuffixOptions, userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("UserId is required");
}
if (keySuffix === KeySuffixOptions.Pin && userId != null) {
// 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
this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
if (keySuffix === KeySuffixOptions.Auto) {
await this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
}
if (keySuffix === KeySuffixOptions.Pin) {
await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
}
}