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

[PM-16541] Key rotation & enrollment trust for emergency access & organizations (#12655)

* Implement key rotation v2

* Pass through masterpassword hint

* Properly split old and new code

* Mark legacy rotation as deprecated

* Throw when data is null

* Cleanup

* Add tests

* Fix build

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

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

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

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

* Add documentation

* Centralize loading logic

* Implement trust dialogs

* Fix build and clean up

* Add tests for accept organization component

* Fix enrollment

* Update apps/web/src/app/admin-console/organizations/manage/organization-trust.component.html

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

* Cleanup according to feedback

* Change div to ng-container

* Init uninited strings

* Fix type errors on dialog config

* Fix typing

* Fix build

* Fix build

* Update libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts

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

* Fix linting

* Undo legacy component import change

* Simplify dialog text

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-04-07 13:41:19 +02:00
committed by GitHub
parent c6821608d0
commit 1c44640ea5
26 changed files with 1048 additions and 103 deletions

View File

@@ -0,0 +1,23 @@
<form [formGroup]="confirmForm" [bitSubmit]="submit">
<bit-dialog
dialogSize="large"
[loading]="loading"
[title]="'trustOrganization' | i18n"
[subtitle]="params.name"
>
<ng-container bitDialogContent>
<bit-callout type="warning">{{ "orgTrustWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "fingerprintPhrase" | i18n }} <code>{{ fingerprint }}</code>
</p>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" bitButton bitFormButton>
<span>{{ "trust" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "doNotTrust" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,69 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
type OrganizationTrustDialogData = {
/** display name of the organization */
name: string;
/** identifies the organization */
orgId: string;
/** org public key */
publicKey: Uint8Array;
};
@Component({
selector: "organization-trust",
templateUrl: "organization-trust.component.html",
})
export class OrganizationTrustComponent implements OnInit {
loading = true;
fingerprint: string = "";
confirmForm = this.formBuilder.group({});
constructor(
@Inject(DIALOG_DATA) protected params: OrganizationTrustDialogData,
private formBuilder: FormBuilder,
private keyService: KeyService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private logService: LogService,
private dialogRef: DialogRef<boolean>,
) {}
async ngOnInit() {
try {
const fingerprint = await this.keyService.getFingerprint(
this.params.orgId,
this.params.publicKey,
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
} catch (e) {
this.logService.error(e);
}
this.loading = false;
}
submit = async () => {
if (this.loading) {
return;
}
this.dialogRef.close(true);
};
/**
* Strongly typed helper to open a OrganizationTrustComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data The data to pass to the dialog
*/
static open(dialogService: DialogService, data: OrganizationTrustDialogData) {
return dialogService.open<boolean, OrganizationTrustDialogData>(OrganizationTrustComponent, {
data,
});
}
}

View File

@@ -0,0 +1,11 @@
export class OrganizationUserResetPasswordEntry {
orgId: string;
publicKey: Uint8Array;
orgName: string;
constructor(orgId: string, publicKey: Uint8Array, orgName: string) {
this.orgId = orgId;
this.publicKey = publicKey;
this.orgName = orgName;
}
}

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
@@ -14,6 +14,7 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } 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 { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -23,6 +24,9 @@ import { KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
const mockPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
describe("OrganizationUserResetPasswordService", () => {
let sut: OrganizationUserResetPasswordService;
@@ -51,6 +55,21 @@ describe("OrganizationUserResetPasswordService", () => {
);
});
beforeEach(() => {
organizationService.organizations$.mockReturnValue(
new BehaviorSubject([
createOrganization("1", "org1", true),
createOrganization("2", "org2", false),
]),
);
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "privateKey",
publicKey: "publicKey",
}),
);
});
afterEach(() => {
jest.resetAllMocks();
});
@@ -59,55 +78,47 @@ describe("OrganizationUserResetPasswordService", () => {
expect(sut).toBeTruthy();
});
describe("getRecoveryKey", () => {
describe("buildRecoveryKey", () => {
const mockOrgId = "test-org-id";
beforeEach(() => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
keyService.getUserKey.mockResolvedValue(mockUserKey);
encryptService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
});
it("should return an encrypted user key", async () => {
const encryptedString = await sut.buildRecoveryKey(mockOrgId);
const encryptedString = await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptedString).toBeDefined();
});
it("should only use the user key from memory if one is not provided", async () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
await sut.buildRecoveryKey(mockOrgId, mockUserKey);
expect(keyService.getUserKey).not.toHaveBeenCalled();
});
it("should throw an error if the organization keys are null", async () => {
organizationApiService.getKeys.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
await expect(sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys)).rejects.toThrow();
});
it("should throw an error if the user key can't be found", async () => {
keyService.getUserKey.mockResolvedValue(null);
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
await expect(sut.buildRecoveryKey(mockOrgId, null, mockPublicKeys)).rejects.toThrow();
});
it("should rsa encrypt the user key", async () => {
await sut.buildRecoveryKey(mockOrgId);
await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(expect.anything(), expect.anything());
});
it("should throw an error if the public key is not trusted", async () => {
await expect(
sut.buildRecoveryKey(mockOrgId, mockUserKey, [new Uint8Array(64)]),
).rejects.toThrow();
});
});
describe("resetMasterPassword", () => {
@@ -163,6 +174,20 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
describe("getPublicKeys", () => {
it("should return public keys for organizations that have reset password enrolled", async () => {
const result = await sut.getPublicKeys("userId" as UserId);
expect(result).toHaveLength(1);
});
it("should result should contain the correct data for the org", async () => {
const result = await sut.getPublicKeys("userId" as UserId);
expect(result[0].orgId).toBe("1");
expect(result[0].orgName).toBe("org1");
expect(result[0].publicKey).toEqual(Utils.fromB64ToArray("publicKey"));
});
});
describe("getRotatedData", () => {
beforeEach(() => {
organizationService.organizations$.mockReturnValue(
@@ -171,7 +196,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
publicKey: "test-public-key",
publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
encryptService.rsaEncrypt.mockResolvedValue(
@@ -182,7 +207,7 @@ describe("OrganizationUserResetPasswordService", () => {
it("should return all re-encrypted account recovery keys", async () => {
const result = await sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
mockPublicKeys,
"mockUserId" as UserId,
);
@@ -191,22 +216,18 @@ describe("OrganizationUserResetPasswordService", () => {
it("throws if the new user key is null", async () => {
await expect(
sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
null,
"mockUserId" as UserId,
),
sut.getRotatedData(null, mockPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});
});
function createOrganization(id: string, name: string) {
function createOrganization(id: string, name: string, resetPasswordEnrolled = true): Organization {
const org = new Organization();
org.id = id;
org.name = name;
org.identifier = name;
org.isMember = true;
org.resetPasswordEnrolled = true;
org.resetPasswordEnrolled = resetPasswordEnrolled;
return org;
}

View File

@@ -21,16 +21,22 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
UserKeyRotationDataProvider,
UserKeyRotationKeyRecoveryProvider,
KeyService,
KdfType,
} from "@bitwarden/key-management";
import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-password-entry";
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
implements
UserKeyRotationKeyRecoveryProvider<
OrganizationUserResetPasswordWithIdRequest,
OrganizationUserResetPasswordEntry
>
{
constructor(
private keyService: KeyService,
@@ -42,11 +48,17 @@ export class OrganizationUserResetPasswordService
) {}
/**
* Returns the user key encrypted by the organization's public key.
* Intended for use in enrollment
* Builds a recovery key for a user to recover their account.
*
* @param orgId desired organization
* @param userKey user key
* @param trustedPublicKeys public keys of organizations that the user trusts
*/
async buildRecoveryKey(orgId: string, userKey?: UserKey): Promise<EncryptedString> {
async buildRecoveryKey(
orgId: string,
userKey: UserKey,
trustedPublicKeys: Uint8Array[],
): Promise<EncryptedString> {
// Retrieve Public Key
const orgKeys = await this.organizationApiService.getKeys(orgId);
if (orgKeys == null) {
@@ -55,13 +67,16 @@ export class OrganizationUserResetPasswordService
const publicKey = Utils.fromB64ToArray(orgKeys.publicKey);
// RSA Encrypt user key with organization's public key
userKey ??= await this.keyService.getUserKey();
if (userKey == null) {
throw new Error("No user key found");
if (
!trustedPublicKeys.some(
(key) => Utils.fromBufferToHex(key) === Utils.fromBufferToHex(publicKey),
)
) {
throw new Error("Untrusted public key");
}
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
// RSA Encrypt user key with organization's public key
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
return encryptedKey.encryptedString;
}
@@ -138,6 +153,21 @@ export class OrganizationUserResetPasswordService
);
}
async getPublicKeys(userId: UserId): Promise<OrganizationUserResetPasswordEntry[]> {
const allOrgs = (await firstValueFrom(this.organizationService.organizations$(userId))).filter(
(org) => org.resetPasswordEnrolled,
);
const entries: OrganizationUserResetPasswordEntry[] = [];
for (const org of allOrgs) {
const publicKey = await this.organizationApiService.getKeys(org.id);
const encodedPublicKey = Utils.fromB64ToArray(publicKey.publicKey);
const entry = new OrganizationUserResetPasswordEntry(org.id, encodedPublicKey, org.name);
entries.push(entry);
}
return entries;
}
/**
* Returns existing account recovery keys re-encrypted with the new user key.
* @param originalUserKey the original user key
@@ -147,8 +177,8 @@ export class OrganizationUserResetPasswordService
* @returns a list of account recovery keys that have been re-encrypted with the new user key
*/
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
if (newUserKey == null) {
@@ -156,9 +186,8 @@ export class OrganizationUserResetPasswordService
}
const allOrgs = await firstValueFrom(this.organizationService.organizations$(userId));
if (!allOrgs) {
return;
throw new Error("Could not get organizations");
}
const requests: OrganizationUserResetPasswordWithIdRequest[] = [];
@@ -169,7 +198,7 @@ export class OrganizationUserResetPasswordService
}
// Re-enroll - encrypt user key with organization public key
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey);
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey, trustedPublicKeys);
// Create/Execute request
const request = new OrganizationUserResetPasswordWithIdRequest();

View File

@@ -1,19 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, lastValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../manage/organization-trust.component";
import { OrganizationUserResetPasswordService } from "../members/services/organization-user-reset-password/organization-user-reset-password.service";
interface EnrollMasterPasswordResetData {
@@ -28,12 +34,14 @@ export class EnrollMasterPasswordReset {
data: EnrollMasterPasswordResetData,
resetPasswordService: OrganizationUserResetPasswordService,
organizationUserApiService: OrganizationUserApiService,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
syncService: SyncService,
logService: LogService,
userVerificationService: UserVerificationService,
toastService: ToastService,
keyService: KeyService,
accountService: AccountService,
organizationApiService: OrganizationApiServiceAbstraction,
) {
const result = await UserVerificationDialogComponent.open(dialogService, {
title: "enrollAccountRecovery",
@@ -44,12 +52,33 @@ export class EnrollMasterPasswordReset {
verificationType: {
type: "custom",
verificationFn: async (secret: VerificationWithSecret) => {
const activeUserId = (await firstValueFrom(accountService.activeAccount$)).id;
const publicKey = Utils.fromB64ToArray(
(await organizationApiService.getKeys(data.organization.id)).publicKey,
);
const request =
await userVerificationService.buildRequest<OrganizationUserResetPasswordEnrollmentRequest>(
secret,
);
const dialogRef = OrganizationTrustComponent.open(dialogService, {
name: data.organization.name,
orgId: data.organization.id,
publicKey,
});
const result = await lastValueFrom(dialogRef.closed);
if (result !== true) {
throw new Error("Organization not trusted, aborting user key rotation");
}
const trustedOrgPublicKeys = [publicKey];
const userKey = await firstValueFrom(keyService.userKey$(activeUserId));
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(
data.organization.id,
userKey,
trustedOrgPublicKeys,
);
// Process the enrollment request, which is an endpoint that is

View File

@@ -42,3 +42,7 @@ export class ViewTypeEmergencyAccess {
keyEncrypted: string;
ciphers: CipherResponse[] = [];
}
export class GranteeEmergencyAccessWithPublicKey extends GranteeEmergencyAccess {
publicKey: Uint8Array;
}

View File

@@ -11,6 +11,7 @@ import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.resp
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } 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 { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -41,6 +42,9 @@ describe("EmergencyAccessService", () => {
let emergencyAccessService: EmergencyAccessService;
let configService: ConfigService;
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
beforeAll(() => {
emergencyAccessApiService = mock<EmergencyAccessApiService>();
apiService = mock<ApiService>();
@@ -226,10 +230,6 @@ describe("EmergencyAccessService", () => {
});
describe("getRotatedData", () => {
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
@@ -250,7 +250,7 @@ describe("EmergencyAccessService", () => {
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
publicKey: "mockPublicKey",
publicKey: Utils.fromUtf8ToB64("trustedPublicKey"),
} as UserKeyResponse);
encryptService.rsaEncrypt.mockImplementation((plainValue, publicKey) => {
@@ -262,17 +262,32 @@ describe("EmergencyAccessService", () => {
it("Only returns emergency accesses with allowed statuses", async () => {
const result = await emergencyAccessService.getRotatedData(
mockOriginalUserKey,
mockNewUserKey,
mockTrustedPublicKeys,
"mockUserId" as UserId,
);
expect(result).toHaveLength(allowedStatuses.length);
});
it("Throws if emergency access public key is not trusted", async () => {
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
publicKey: Utils.fromUtf8ToB64("untrustedPublicKey"),
} as UserKeyResponse);
await expect(
emergencyAccessService.getRotatedData(
mockNewUserKey,
mockTrustedPublicKeys,
"mockUserId" as UserId,
),
).rejects.toThrow("Public key for user is not trusted.");
});
it("throws if new user key is null", async () => {
await expect(
emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
emergencyAccessService.getRotatedData(null, mockTrustedPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});

View File

@@ -22,14 +22,18 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
UserKeyRotationDataProvider,
KeyService,
KdfType,
UserKeyRotationKeyRecoveryProvider,
} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
import { GranteeEmergencyAccess, GrantorEmergencyAccess } from "../models/emergency-access";
import {
GranteeEmergencyAccess,
GranteeEmergencyAccessWithPublicKey,
GrantorEmergencyAccess,
} from "../models/emergency-access";
import { EmergencyAccessAcceptRequest } from "../request/emergency-access-accept.request";
import { EmergencyAccessConfirmRequest } from "../request/emergency-access-confirm.request";
import { EmergencyAccessInviteRequest } from "../request/emergency-access-invite.request";
@@ -38,12 +42,17 @@ import {
EmergencyAccessUpdateRequest,
EmergencyAccessWithIdRequest,
} from "../request/emergency-access-update.request";
import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService
implements UserKeyRotationDataProvider<EmergencyAccessWithIdRequest>
implements
UserKeyRotationKeyRecoveryProvider<
EmergencyAccessWithIdRequest,
GranteeEmergencyAccessWithPublicKey
>
{
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
@@ -301,30 +310,12 @@ export class EmergencyAccessService
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
}
/**
* Returns existing emergency access keys re-encrypted with new user key.
* Intended for grantor.
* @param originalUserKey the original user key
* @param newUserKey the new user key
* @param userId the user id
* @throws Error if newUserKey is nullish
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
*/
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
): Promise<EmergencyAccessWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const requests: EmergencyAccessWithIdRequest[] = [];
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {
const existingEmergencyAccess =
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
if (!existingEmergencyAccess || existingEmergencyAccess.data.length === 0) {
return requests;
return [];
}
// Any Invited or Accepted requests won't have the key yet, so we don't need to update them
@@ -337,13 +328,73 @@ export class EmergencyAccessService
allowedStatuses.has(d.status),
);
for (const details of filteredAccesses) {
// Get public key of grantee
const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
return filteredAccesses;
}
async getPublicKeys(): Promise<GranteeEmergencyAccessWithPublicKey[]> {
const emergencyAccessData = await this.getEmergencyAccessData();
const emergencyAccessDataWithPublicKeys = await Promise.all(
emergencyAccessData.map(async (details) => {
const grantee = new GranteeEmergencyAccessWithPublicKey();
grantee.id = details.id;
grantee.granteeId = details.granteeId;
grantee.name = details.name;
grantee.email = details.email;
grantee.type = details.type;
grantee.status = details.status;
grantee.waitTimeDays = details.waitTimeDays;
grantee.creationDate = details.creationDate;
grantee.avatarColor = details.avatarColor;
grantee.publicKey = Utils.fromB64ToArray(
(await this.apiService.getUserPublicKey(details.granteeId)).publicKey,
);
return grantee;
}),
);
return emergencyAccessDataWithPublicKeys;
}
/**
* Returns existing emergency access keys re-encrypted with new user key.
* Intended for grantor.
* @param newUserKey the new user key
* @param trustedPublicKeys the public keys of the emergency access grantors. These *must* be trusted somehow, and MUST NOT be passed in untrusted
* @param userId the user id
* @throws Error if newUserKey is nullish
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
*/
async getRotatedData(
newUserKey: UserKey,
trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise<EmergencyAccessWithIdRequest[]> {
if (newUserKey == null) {
throw new Error("New user key is required for rotation.");
}
const requests: EmergencyAccessWithIdRequest[] = [];
this.logService.info(
"Starting emergency access rotation, with trusted keys: ",
trustedPublicKeys,
);
const allDetails = await this.getPublicKeys();
for (const details of allDetails) {
if (
trustedPublicKeys.find(
(pk) => Utils.fromBufferToHex(pk) === Utils.fromBufferToHex(details.publicKey),
) == null
) {
this.logService.info(
`Public key for user ${details.granteeId} is not trusted, skipping rotation.`,
);
throw new Error("Public key for user is not trusted.");
}
// Encrypt new user key with public key
const encryptedKey = await this.encryptKey(newUserKey, publicKey);
const encryptedKey = await this.encryptKey(newUserKey, details.publicKey);
const updateRequest = new EmergencyAccessWithIdRequest();
updateRequest.id = details.id;

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -11,14 +12,19 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
import { I18nService } from "../../core/i18n.service";
import {
@@ -41,6 +47,8 @@ describe("AcceptOrganizationInviteService", () => {
let i18nService: MockProxy<I18nService>;
let globalStateProvider: FakeGlobalStateProvider;
let globalState: FakeGlobalState<OrganizationInvite>;
let dialogService: MockProxy<DialogService>;
let accountService: MockProxy<AccountService>;
beforeEach(() => {
apiService = mock();
@@ -55,6 +63,8 @@ describe("AcceptOrganizationInviteService", () => {
i18nService = mock();
globalStateProvider = new FakeGlobalStateProvider();
globalState = globalStateProvider.getFake(ORGANIZATION_INVITE);
dialogService = mock();
accountService = mock();
sut = new AcceptOrganizationInviteService(
apiService,
@@ -68,6 +78,8 @@ describe("AcceptOrganizationInviteService", () => {
organizationUserApiService,
i18nService,
globalStateProvider,
dialogService,
accountService,
);
});
@@ -142,7 +154,7 @@ describe("AcceptOrganizationInviteService", () => {
expect(authService.logOut).not.toHaveBeenCalled();
});
it("accepts the invitation request when the org has a master password policy, but the user has already passed it", async () => {
it("accepts the invitation request when the org has a master password policy, but the user has already passed it and autoenroll is not enabled", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
@@ -167,6 +179,53 @@ describe("AcceptOrganizationInviteService", () => {
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
it("accepts the invitation request and enrolls when autoenroll is enabled", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
type: PolicyType.MasterPassword,
enabled: true,
} as Policy,
]);
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "privateKey",
publicKey: "publicKey",
}),
);
accountService.activeAccount$ = new BehaviorSubject({ id: "activeUserId" }) as any;
keyService.userKey$.mockReturnValue(new BehaviorSubject({ key: "userKey" } as any));
encryptService.rsaEncrypt.mockResolvedValue({
encryptedString: "encryptedString",
} as EncString);
jest.mock("../../admin-console/organizations/manage/organization-trust.component");
OrganizationTrustComponent.open = jest.fn().mockReturnValue({
closed: new BehaviorSubject(true),
});
await globalState.update(() => invite);
policyService.getResetPasswordPolicyOptions.mockReturnValue([
{
autoEnrollEnabled: true,
} as ResetPasswordPolicyOptions,
true,
]);
const result = await sut.validateAndAcceptInvite(invite);
expect(result).toBe(true);
expect(OrganizationTrustComponent.open).toHaveBeenCalled();
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(
"userKey",
Utils.fromB64ToArray("publicKey"),
);
expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
});
});

View File

@@ -15,6 +15,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -27,8 +28,11 @@ import {
ORGANIZATION_INVITE_DISK,
} from "@bitwarden/common/platform/state";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
import { OrganizationInvite } from "./organization-invite";
// We're storing the organization invite for 2 reasons:
@@ -63,6 +67,8 @@ export class AcceptOrganizationInviteService {
private readonly organizationUserApiService: OrganizationUserApiService,
private readonly i18nService: I18nService,
private readonly globalStateProvider: GlobalStateProvider,
private readonly dialogService: DialogService,
private readonly accountService: AccountService,
) {
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
}
@@ -183,9 +189,19 @@ export class AcceptOrganizationInviteService {
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
const dialogRef = OrganizationTrustComponent.open(this.dialogService, {
name: invite.organizationName,
orgId: invite.organizationId,
publicKey,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== true) {
throw new Error("Organization not trusted, aborting user key rotation");
}
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$)).id;
const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
// RSA Encrypt user's encKey.key with organization public key
const userKey = await this.keyService.getUserKey();
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
// Add reset password key to accept request

View File

@@ -23,17 +23,47 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
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 { ToastService } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService, DEFAULT_KDF_CONFIG } from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
import { WebauthnLoginAdminService } from "../../auth";
import { EmergencyAccessService } from "../../auth/emergency-access";
import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/emergency-access-status-type";
import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type";
import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
import { UserKeyRotationService } from "./user-key-rotation.service";
const initialPromptedOpenTrue = jest.fn();
initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) });
const initialPromptedOpenFalse = jest.fn();
initialPromptedOpenFalse.mockReturnValue({ closed: new BehaviorSubject(false) });
const emergencyAccessTrustOpenTrusted = jest.fn();
emergencyAccessTrustOpenTrusted.mockReturnValue({
closed: new BehaviorSubject(true),
});
const emergencyAccessTrustOpenUntrusted = jest.fn();
emergencyAccessTrustOpenUntrusted.mockReturnValue({
closed: new BehaviorSubject(false),
});
const accountRecoveryTrustOpenTrusted = jest.fn();
accountRecoveryTrustOpenTrusted.mockReturnValue({
closed: new BehaviorSubject(true),
});
const accountRecoveryTrustOpenUntrusted = jest.fn();
accountRecoveryTrustOpenUntrusted.mockReturnValue({
closed: new BehaviorSubject(false),
});
describe("KeyRotationService", () => {
let keyRotationService: UserKeyRotationService;
@@ -52,6 +82,7 @@ describe("KeyRotationService", () => {
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
let mockLogService: MockProxy<LogService>;
let mockVaultTimeoutService: MockProxy<VaultTimeoutService>;
let mockDialogService: MockProxy<DialogService>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
@@ -62,6 +93,8 @@ describe("KeyRotationService", () => {
name: "mockName",
};
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
beforeAll(() => {
mockUserVerificationService = mock<UserVerificationService>();
mockApiService = mock<UserKeyRotationApiService>();
@@ -69,7 +102,32 @@ describe("KeyRotationService", () => {
mockFolderService = mock<FolderService>();
mockSendService = mock<SendService>();
mockEmergencyAccessService = mock<EmergencyAccessService>();
mockEmergencyAccessService.getPublicKeys.mockResolvedValue(
mockTrustedPublicKeys.map((key) => {
return {
publicKey: key,
id: "mockId",
granteeId: "mockGranteeId",
name: "mockName",
email: "mockEmail",
type: EmergencyAccessType.Takeover,
status: EmergencyAccessStatusType.Accepted,
waitTimeDays: 5,
creationDate: "mockCreationDate",
avatarColor: "mockAvatarColor",
};
}),
);
mockResetPasswordService = mock<OrganizationUserResetPasswordService>();
mockResetPasswordService.getPublicKeys.mockResolvedValue(
mockTrustedPublicKeys.map((key) => {
return {
publicKey: key,
orgId: "mockOrgId",
orgName: "mockOrgName",
};
}),
);
mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
mockKeyService = mock<KeyService>();
mockEncryptService = mock<EncryptService>();
@@ -80,6 +138,7 @@ describe("KeyRotationService", () => {
mockVaultTimeoutService = mock<VaultTimeoutService>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
mockDialogService = mock<DialogService>();
keyRotationService = new UserKeyRotationService(
mockUserVerificationService,
@@ -98,9 +157,14 @@ describe("KeyRotationService", () => {
mockVaultTimeoutService,
mockToastService,
mockI18nService,
mockDialogService,
);
});
beforeEach(() => {
jest.mock("@bitwarden/key-management-ui");
});
beforeEach(() => {
jest.clearAllMocks();
});
@@ -134,6 +198,8 @@ describe("KeyRotationService", () => {
// Mock user key
mockKeyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]);
// Mock private key
privateKey = new BehaviorSubject("mockPrivateKey" as any);
mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
@@ -184,15 +250,21 @@ describe("KeyRotationService", () => {
expect(arg.webauthnKeys.length).toBe(2);
});
it("rotates the user key and encrypted data", async () => {
it("rotates the userkey and encrypted data and changes master password", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockNewMasterPassword",
"newMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
expect(arg.accountUnlockData.masterPasswordUnlockData.masterKeyEncryptedUserKey).toBe(
"mockNewUserKey",
);
expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
@@ -201,11 +273,52 @@ describe("KeyRotationService", () => {
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);
expect(arg.accountUnlockData.emergencyAccessUnlockData.length).toBe(1);
expect(arg.accountUnlockData.organizationAccountRecoveryUnlockData.length).toBe(1);
expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
});
it("returns early when first trust warning dialog is declined", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenFalse;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"newMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
});
it("returns early when emergency access trust warning dialog is declined", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenUntrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"newMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
});
it("returns early when account recovery trust warning dialog is declined", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenUntrusted;
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"newMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
});
it("legacy throws if master password provided is falsey", async () => {
@@ -296,6 +409,9 @@ describe("KeyRotationService", () => {
});
it("throws if server rotation fails", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError"));
await expect(

View File

@@ -19,8 +19,13 @@ import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -53,6 +58,7 @@ export class UserKeyRotationService {
private vaultTimeoutService: VaultTimeoutService,
private toastService: ToastService,
private i18nService: I18nService,
private dialogService: DialogService,
) {}
/**
@@ -81,6 +87,20 @@ export class UserKeyRotationService {
);
}
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 {
masterKey: oldMasterKey,
email,
@@ -156,25 +176,70 @@ export class UserKeyRotationService {
}
const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
for (const details of emergencyAccessGrantees) {
this.logService.info("[Userkey rotation] Emergency access grantee: " + details.name);
this.logService.info(
"[Userkey rotation] Emergency access grantee fingerprint: " +
(await this.keyService.getFingerprint(details.granteeId, details.publicKey)).join("-"),
);
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(
originalUserKey,
newUnencryptedUserKey,
trustedUserPublicKeys,
user.id,
);
for (const organization of orgs) {
this.logService.info(
"[Userkey rotation] Reset password organization: " + organization.orgName,
);
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 {
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(
originalUserKey,
const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
newUnencryptedUserKey,
trustedOrgPublicKeys,
user.id,
);
if (organizationAccountRecoveryUnlockData == null) {
this.logService.info(
"[Userkey rotation] Organization account recovery data is null. Aborting!",
);
throw new Error("Organization account recovery data is null");
}
))!;
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
@@ -236,6 +301,9 @@ export class UserKeyRotationService {
);
}
const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
const orgs = await this.resetPasswordService.getPublicKeys(user.id);
// Verify master password
// UV service sets master key on success since it is stored in memory and can be lost on refresh
const verification = {
@@ -307,20 +375,22 @@ export class UserKeyRotationService {
request.sends = rotatedSends;
}
const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData(
originalUserKey,
newUserKey,
trustedUserPublicKeys,
user.id,
);
if (rotatedEmergencyAccessKeys != null) {
request.emergencyAccessKeys = rotatedEmergencyAccessKeys;
}
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 rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData(
originalUserKey,
newUserKey,
trustedOrgPublicKeys,
user.id,
);
if (rotatedResetPasswordKeys != null) {

View File

@@ -9,6 +9,7 @@ import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";
import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component";
import { OrganizationTrustComponent } from "../admin-console/organizations/manage/organization-trust.component";
import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component";
import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component";
@@ -128,6 +129,7 @@ import { SharedModule } from "./shared.module";
OrgReusedPasswordsReportComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserConfirmComponent,
OrganizationTrustComponent,
OrgWeakPasswordsReportComponent,
PreferencesComponent,
PremiumBadgeComponent,
@@ -186,6 +188,7 @@ import { SharedModule } from "./shared.module";
OrgReusedPasswordsReportComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserConfirmComponent,
OrganizationTrustComponent,
OrgWeakPasswordsReportComponent,
PreferencesComponent,
PremiumBadgeComponent,

View File

@@ -32,6 +32,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
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 { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component";
@@ -70,6 +71,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private configService: ConfigService,
private organizationService: OrganizationService,
private keyService: KeyService,
private accountService: AccountService,
private linkSsoService: LinkSsoService,
) {}
@@ -221,12 +223,14 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
{ organization: org },
this.resetPasswordService,
this.organizationUserApiService,
this.platformUtilsService,
this.i18nService,
this.syncService,
this.logService,
this.userVerificationService,
this.toastService,
this.keyService,
this.accountService,
this.organizationApiService,
);
} else {
// Remove reset password

View File

@@ -5902,6 +5902,9 @@
"fingerprint": {
"message": "Fingerprint"
},
"fingerprintPhrase": {
"message": "Fingerprint phrase:"
},
"removeUsers": {
"message": "Remove users"
},
@@ -10355,6 +10358,33 @@
"organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters."
},
"rotationCompletedTitle": {
"message": "Key rotation successful"
},
"rotationCompletedDesc": {
"message": "Your master password and encryption keys have been updated. Your other devices have been logged out."
},
"trustUserEmergencyAccess": {
"message": "Trust and confirm user"
},
"trustOrganization": {
"message": "Trust organization"
},
"trust": {
"message": "Trust"
},
"doNotTrust": {
"message": "Do not trust"
},
"emergencyAccessTrustWarning": {
"message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account"
},
"orgTrustWarning": {
"message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint."
},
"trustUser":{
"message": "Trust user"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
},
@@ -10524,6 +10554,30 @@
"assignedExceedsAvailable": {
"message": "Assigned seats exceed available seats."
},
"userkeyRotationDisclaimerEmergencyAccessText": {
"message": "Fingerprint phrase for $NUM_USERS$ contacts for which you have enabled emergency access.",
"placeholders": {
"num_users": {
"content": "$1",
"example": "5"
}
}
},
"userkeyRotationDisclaimerAccountRecoveryOrgsText": {
"message": "Fingerprint phrase for the organization $ORG_NAME$ for which you have enabled account recovery.",
"placeholders": {
"org_name": {
"content": "$1",
"example": "My org"
}
}
},
"userkeyRotationDisclaimerDescription": {
"message": "Rotating your encryption keys will require you to trust keys of any organizations that can recover your account, and any contacts that can you have enabled emergency access for. To continue, make sure you can verify the following:"
},
"userkeyRotationDisclaimerTitle": {
"message": "Untrusted encryption keys"
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
},

View File

@@ -4,3 +4,6 @@
export { LockComponent } from "./lock/components/lock.component";
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";

View File

@@ -0,0 +1,26 @@
<bit-dialog dialogSize="large">
<span bitDialogTitle>
<strong> {{ "userkeyRotationDisclaimerTitle" | i18n }} </strong>
</span>
<span bitDialogContent>
{{ "userkeyRotationDisclaimerDescription" | i18n }}
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
<li *ngIf="params.orgName != null">
{{ "userkeyRotationDisclaimerAccountRecoveryOrgsText" | i18n: params.orgName }}
</li>
<li *ngIf="params.numberOfEmergencyAccessUsers > 0">
{{
"userkeyRotationDisclaimerEmergencyAccessText" | i18n: params.numberOfEmergencyAccessUsers
}}
</li>
</ul>
</span>
<ng-container bitDialogFooter>
<a bitButton target="_blank" rel="noreferrer" buttonType="primary" (click)="submit()">
{{ "continue" | i18n }}
</a>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,58 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
} from "@bitwarden/components";
type KeyRotationTrustDialogData = {
orgName?: string;
numberOfEmergencyAccessUsers: number;
};
@Component({
selector: "key-rotation-trust-info",
templateUrl: "key-rotation-trust-info.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
DialogModule,
ButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormsModule,
],
})
export class KeyRotationTrustInfoComponent {
constructor(
@Inject(DIALOG_DATA) protected params: KeyRotationTrustDialogData,
private logService: LogService,
private dialogRef: DialogRef<boolean>,
) {}
async submit() {
try {
this.dialogRef.close(true);
} catch (e) {
this.logService.error(e);
}
}
/**
* Strongly typed helper to open a KeyRotationTrustComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data The data to pass to the dialog
*/
static open(dialogService: DialogService, data: KeyRotationTrustDialogData) {
return dialogService.open<boolean, KeyRotationTrustDialogData>(KeyRotationTrustInfoComponent, {
data,
});
}
}

View File

@@ -0,0 +1,21 @@
<bit-dialog
dialogSize="large"
[loading]="loading"
[title]="'trustOrganization' | i18n"
[subtitle]="params.name"
>
<ng-container bitDialogContent>
<bit-callout type="warning">{{ "orgTrustWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "fingerprintPhrase" | i18n }} <code>{{ fingerprint }}</code>
</p>
</ng-container>
<ng-container bitDialogFooter>
<button buttonType="primary" bitButton bitFormButton type="button" (click)="submit()">
<span>{{ "trust" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "doNotTrust" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,94 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AsyncActionsModule,
ButtonModule,
CalloutModule,
DialogModule,
DialogService,
FormFieldModule,
LinkModule,
TypographyModule,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
type AccountRecoveryTrustDialogData = {
/** display name of the user */
name: string;
/** org id */
orgId: string;
/** org public key */
publicKey: Uint8Array;
};
@Component({
selector: "account-recovery-trust",
templateUrl: "account-recovery-trust.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,
ReactiveFormsModule,
FormFieldModule,
AsyncActionsModule,
FormsModule,
CalloutModule,
],
})
export class AccountRecoveryTrustComponent implements OnInit {
loading = true;
fingerprint: string = "";
confirmForm = this.formBuilder.group({});
constructor(
@Inject(DIALOG_DATA) protected params: AccountRecoveryTrustDialogData,
private formBuilder: FormBuilder,
private keyService: KeyService,
private logService: LogService,
private dialogRef: DialogRef<boolean>,
) {}
async ngOnInit() {
try {
const fingerprint = await this.keyService.getFingerprint(
this.params.orgId,
this.params.publicKey,
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
} catch (e) {
this.logService.error(e);
}
this.loading = false;
}
async submit() {
if (this.loading) {
return;
}
this.dialogRef.close(true);
}
/**
* Strongly typed helper to open a AccountRecoveryTrustComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data The data to pass to the dialog
*/
static open(dialogService: DialogService, data: AccountRecoveryTrustDialogData) {
return dialogService.open<boolean, AccountRecoveryTrustDialogData>(
AccountRecoveryTrustComponent,
{
data,
},
);
}
}

View File

@@ -0,0 +1,32 @@
<bit-dialog
dialogSize="large"
[loading]="loading"
[title]="'trustUser' | i18n"
[subtitle]="params.name"
>
<ng-container bitDialogContent>
<bit-callout type="warning">{{ "emergencyAccessTrustWarning" | i18n }}</bit-callout>
<p bitTypography="body1">
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
>
{{ "learnMore" | i18n }}</a
>
</p>
<p bitTypography="body1">
<code>{{ fingerprint }}</code>
</p>
</ng-container>
<ng-container bitDialogFooter>
<button buttonType="primary" bitButton bitFormButton type="button" (click)="submit()">
<span>{{ "trust" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "doNotTrust" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,94 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnInit, Inject } from "@angular/core";
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
AsyncActionsModule,
ButtonModule,
CalloutModule,
DialogModule,
DialogService,
FormFieldModule,
LinkModule,
TypographyModule,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
type EmergencyAccessTrustDialogData = {
/** display name of the user */
name: string;
/** userid of the user */
userId: string;
/** user public key */
publicKey: Uint8Array;
};
@Component({
selector: "emergency-access-trust",
templateUrl: "emergency-access-trust.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,
ReactiveFormsModule,
FormFieldModule,
AsyncActionsModule,
FormsModule,
CalloutModule,
],
})
export class EmergencyAccessTrustComponent implements OnInit {
loading = true;
fingerprint: string = "";
confirmForm = this.formBuilder.group({});
constructor(
@Inject(DIALOG_DATA) protected params: EmergencyAccessTrustDialogData,
private formBuilder: FormBuilder,
private keyService: KeyService,
private logService: LogService,
private dialogRef: DialogRef<boolean, EmergencyAccessTrustComponent>,
) {}
async ngOnInit() {
try {
const fingerprint = await this.keyService.getFingerprint(
this.params.userId,
this.params.publicKey,
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
} catch (e) {
this.logService.error(e);
}
this.loading = false;
}
async submit() {
if (this.loading) {
return;
}
this.dialogRef.close(true);
}
/**
* Strongly typed helper to open a EmergencyAccessTrustComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data The data to pass to the dialog
*/
static open(dialogService: DialogService, data: EmergencyAccessTrustDialogData) {
return dialogService.open<boolean, EmergencyAccessTrustDialogData>(
EmergencyAccessTrustComponent,
{
data,
},
);
}
}

View File

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

View File

@@ -0,0 +1,31 @@
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
/**
* Constructs key rotation requests for key recovery encryption of the userkey.
* @typeparam TRequest A request model that contains the newly encrypted userkey must have an id property
*/
export interface UserKeyRotationKeyRecoveryProvider<
TRequest extends { id: string } | { organizationId: string },
TPublicKeyData,
> {
/**
* Get the public keys for this recovery method from the server.
* WARNING these are NOT trusted, and need to either be manually trusted by the user, or compared against
* a signed trust database for the user. THE SERVER CAN SPOOF THESE.
*/
getPublicKeys(userId: UserId): Promise<TPublicKeyData[]>;
/**
* Provides re-encrypted data for the user key rotation process
* @param newUserKey The new user key
* @param trustedPublicKeys The public keys that the user trusted
* @param userId The owner of the data, useful for fetching data
* @returns A list of data that has been re-encrypted with the new user key
*/
getRotatedData(
newUserKey: UserKey,
trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise<TRequest[]>;
}

View File

@@ -10,6 +10,7 @@ export * from "./biometrics/biometric.state";
export { CipherDecryptionKeys, KeyService } from "./abstractions/key.service";
export { DefaultKeyService } from "./key.service";
export { UserKeyRotationDataProvider } from "./abstractions/user-key-rotation-data-provider.abstraction";
export { UserKeyRotationKeyRecoveryProvider } from "./abstractions/user-key-rotation-key-recovery-provider.abstraction";
export {
PBKDF2KdfConfig,
Argon2KdfConfig,