1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 16:43:27 +00:00

[PM-30144] Implement client-side user-key-rotation-service (#18285)

* Implement client-side user-key-rotation-service

* Feature flag

* Add tests

* Fix flag name

* Fix build

* Prettier

* Small clean-up

* Codeowners order cleanup

* Fix eslint issue

* Update sdk to 550

* Cleanup & fix incompatibilities

* Prettier
This commit is contained in:
Bernd Schoolmann
2026-02-20 15:28:24 +01:00
committed by GitHub
parent 40c8139e1c
commit bb110122a5
22 changed files with 674 additions and 1 deletions

1
.github/CODEOWNERS vendored
View File

@@ -192,6 +192,7 @@ apps/cli/src/key-management @bitwarden/team-key-management-dev
bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/user-crypto-management @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
# Node-cryptofunction service
libs/node @bitwarden/team-key-management-dev

View File

@@ -57,6 +57,7 @@ import {
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal";
import { UserKeyRotationServiceAbstraction } from "@bitwarden/user-crypto-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth";
@@ -287,6 +288,7 @@ describe("KeyRotationService", () => {
let mockSdkClientFactory: MockProxy<SdkClientFactory>;
let mockSecurityStateService: MockProxy<SecurityStateService>;
let mockMasterPasswordService: MockProxy<MasterPasswordServiceAbstraction>;
let mockSdkUserKeyRotationService: MockProxy<UserKeyRotationServiceAbstraction>;
const mockUser = {
id: "mockUserId" as UserId,
@@ -348,6 +350,7 @@ describe("KeyRotationService", () => {
mockDialogService = mock<DialogService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockKdfConfigService = mock<KdfConfigService>();
mockSdkUserKeyRotationService = mock<UserKeyRotationServiceAbstraction>();
mockSdkClientFactory = mock<SdkClientFactory>();
mockSdkClientFactory.createSdkClient.mockResolvedValue({
crypto: () => {
@@ -358,6 +361,7 @@ describe("KeyRotationService", () => {
} as any;
},
} as BitwardenClient);
mockSecurityStateService = mock<SecurityStateService>();
mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
@@ -384,6 +388,7 @@ describe("KeyRotationService", () => {
mockSdkClientFactory,
mockSecurityStateService,
mockMasterPasswordService,
mockSdkUserKeyRotationService,
);
});
@@ -509,7 +514,12 @@ describe("KeyRotationService", () => {
);
mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
mockConfigService.getFeatureFlag.mockResolvedValue(true);
mockConfigService.getFeatureFlag.mockImplementation(async (flag: FeatureFlag) => {
if (flag === FeatureFlag.EnrollAeadOnKeyRotation) {
return true;
}
return false;
});
const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({
userKey: TEST_VECTOR_USER_KEY_V2,

View File

@@ -39,6 +39,7 @@ import {
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal";
import { UserKeyRotationServiceAbstraction } from "@bitwarden/user-crypto-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -101,6 +102,7 @@ export class UserKeyRotationService {
private sdkClientFactory: SdkClientFactory,
private securityStateService: SecurityStateService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private sdkUserKeyRotationService: UserKeyRotationServiceAbstraction,
) {}
/**
@@ -116,6 +118,28 @@ export class UserKeyRotationService {
user: Account,
newMasterPasswordHint?: string,
): Promise<void> {
const useSdkKeyRotation = await this.configService.getFeatureFlag(FeatureFlag.SdkKeyRotation);
if (useSdkKeyRotation) {
this.logService.info(
"[UserKey Rotation] Using SDK-based key rotation service from user-crypto-management",
);
await this.sdkUserKeyRotationService.changePasswordAndRotateUserKey(
currentMasterPassword,
newMasterPassword,
newMasterPasswordHint,
asUuid(user.id),
);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("rotationCompletedTitle"),
message: this.i18nService.t("rotationCompletedDesc"),
timeout: 15000,
});
await this.logoutService.logout(user.id);
return;
}
// Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized.
await SdkLoadService.Ready;

View File

@@ -61,6 +61,7 @@ module.exports = {
"<rootDir>/libs/vault/jest.config.js",
"<rootDir>/libs/auto-confirm/jest.config.js",
"<rootDir>/libs/subscription/jest.config.js",
"<rootDir>/libs/user-crypto-management/jest.config.js",
],
// Workaround for a memory leak that crashes tests in CI:

View File

@@ -41,6 +41,7 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
SdkKeyRotation = "pm-30144-sdk-key-rotation",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
@@ -157,6 +158,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.SdkKeyRotation]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,

View File

@@ -0,0 +1,5 @@
# user-crypto-management
Owned by: key-management
Manage a user's cryptography and cryptographic settings

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: "user-crypto-management",
preset: "../../jest.preset.js",
testEnvironment: "node",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/user-crypto-management",
};

View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/user-crypto-management",
"version": "0.0.1",
"description": "Manage a user&#39;s cryptography and cryptographic settings",
"private": true,
"type": "commonjs",
"main": "index.js",
"types": "index.d.ts",
"license": "GPL-3.0",
"author": "key-management"
}

View File

@@ -0,0 +1,34 @@
{
"name": "user-crypto-management",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/user-crypto-management/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/user-crypto-management",
"main": "libs/user-crypto-management/src/index.ts",
"tsConfig": "libs/user-crypto-management/tsconfig.lib.json",
"assets": ["libs/user-crypto-management/*.md"],
"rootDir": "libs/user-crypto-management/src"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/user-crypto-management/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/user-crypto-management/jest.config.js"
}
}
}
}

View File

@@ -0,0 +1,3 @@
export { DefaultUserKeyRotationService as UserKeyRotationService } from "./user-key-rotation.service";
export { UserKeyRotationService as UserKeyRotationServiceAbstraction } from "./user-key-rotation.service.abstraction";
export { UserCryptoManagementModule } from "./user-crypto-management.module";

View File

@@ -0,0 +1,25 @@
import { NgModule } from "@angular/core";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { DialogService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { safeProvider } from "@bitwarden/ui-common";
import { DefaultUserKeyRotationService } from "./user-key-rotation.service";
import { UserKeyRotationService } from "./user-key-rotation.service.abstraction";
/**
* Angular module that provides user crypto management services.
* This module handles key rotation and trust verification for organizations
* and emergency access users.
*/
@NgModule({
providers: [
safeProvider({
provide: UserKeyRotationService,
useClass: DefaultUserKeyRotationService,
deps: [SdkService, LogService, DialogService],
}),
],
})
export class UserCryptoManagementModule {}

View File

@@ -0,0 +1,41 @@
import { PublicKey } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
/**
* Result of the trust verification process.
*/
export type TrustVerificationResult = {
wasTrustDenied: boolean;
trustedOrganizationPublicKeys: PublicKey[];
trustedEmergencyAccessUserPublicKeys: PublicKey[];
};
/**
* Abstraction for the user key rotation service.
* Provides functionality to rotate user keys and verify trust for organizations
* and emergency access users.
*/
export abstract class UserKeyRotationService {
/**
* Rotates the user key using the SDK, re-encrypting all required data with the new key.
* @param currentMasterPassword The current master password
* @param newMasterPassword The new master password
* @param hint Optional hint for the new master password
* @param userId The user account ID
*/
abstract changePasswordAndRotateUserKey(
currentMasterPassword: string,
newMasterPassword: string,
hint: string | undefined,
userId: UserId,
): Promise<void>;
/**
* Verifies the trust of organizations and emergency access users by prompting the user.
* Since organizations and emergency access grantees are not signed, manual trust prompts
* are required to verify that the server does not inject public keys.
* @param user The user account
* @returns TrustVerificationResult containing whether trust was denied and the trusted public keys
*/
abstract verifyTrust(userId: UserId): Promise<TrustVerificationResult>;
}

View File

@@ -0,0 +1,295 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { DialogService } from "@bitwarden/components";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { DefaultUserKeyRotationService } from "./user-key-rotation.service";
// Mock dialog open functions
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),
});
// Mock the key-management-ui module before importing components
jest.mock("@bitwarden/key-management-ui", () => ({
KeyRotationTrustInfoComponent: {
open: jest.fn(),
},
EmergencyAccessTrustComponent: {
open: jest.fn(),
},
AccountRecoveryTrustComponent: {
open: jest.fn(),
},
}));
describe("DefaultUserKeyRotationService", () => {
let service: DefaultUserKeyRotationService;
let mockSdkService: MockProxy<SdkService>;
let mockLogService: MockProxy<LogService>;
let mockDialogService: MockProxy<DialogService>;
const mockUserId = "mockUserId" as UserId;
let mockUserCryptoManagement: {
get_untrusted_emergency_access_public_keys: jest.Mock;
get_untrusted_organization_public_keys: jest.Mock;
};
beforeEach(() => {
jest.clearAllMocks();
mockSdkService = mock<SdkService>();
mockLogService = mock<LogService>();
mockDialogService = mock<DialogService>();
mockUserCryptoManagement = {
get_untrusted_emergency_access_public_keys: jest.fn(),
get_untrusted_organization_public_keys: jest.fn(),
};
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
const mockSdkClient = {
take: jest.fn().mockReturnValue({
value: {
user_crypto_management: () => mockUserCryptoManagement,
},
[Symbol.dispose]: jest.fn(),
}),
};
mockSdkService.userClient$.mockReturnValue(of(mockSdkClient as any));
service = new DefaultUserKeyRotationService(mockSdkService, mockLogService, mockDialogService);
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
});
describe("verifyTrust", () => {
const mockEmergencyAccessMembership = {
id: "mockId",
name: "mockName",
public_key: new Uint8Array([1, 2, 3]),
};
const mockOrganizationMembership = {
organization_id: "mockOrgId",
name: "mockOrgName",
public_key: new Uint8Array([4, 5, 6]),
};
it("returns empty arrays if initial dialog is closed", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenFalse;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([
mockOrganizationMembership,
]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(trustedEmergencyAccessUsers).toEqual([]);
expect(trustedOrgs).toEqual([]);
expect(wasTrustDenied).toBe(true);
});
it("returns empty arrays if account recovery dialog is closed", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenUntrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([
mockOrganizationMembership,
]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(trustedEmergencyAccessUsers).toEqual([]);
expect(trustedOrgs).toEqual([]);
expect(wasTrustDenied).toBe(true);
});
it("returns empty arrays if emergency access dialog is closed", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenUntrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(trustedEmergencyAccessUsers).toEqual([]);
expect(trustedOrgs).toEqual([]);
expect(wasTrustDenied).toBe(true);
});
it("returns trusted keys when all dialogs are confirmed with only emergency access users", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(wasTrustDenied).toBe(false);
expect(trustedEmergencyAccessUsers).toEqual([mockEmergencyAccessMembership.public_key]);
expect(trustedOrgs).toEqual([]);
});
it("returns trusted keys when all dialogs are confirmed with only organizations", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([
mockOrganizationMembership,
]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(wasTrustDenied).toBe(false);
expect(trustedEmergencyAccessUsers).toEqual([]);
expect(trustedOrgs).toEqual([mockOrganizationMembership.public_key]);
});
it("returns empty arrays when no organizations or emergency access users exist", async () => {
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(wasTrustDenied).toBe(false);
expect(trustedEmergencyAccessUsers).toEqual([]);
expect(trustedOrgs).toEqual([]);
});
it("returns trusted keys when all dialogs are confirmed with both organizations and emergency access users", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([
mockOrganizationMembership,
]);
const {
wasTrustDenied,
trustedOrganizationPublicKeys: trustedOrgs,
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
} = await service.verifyTrust(mockUserId);
expect(wasTrustDenied).toBe(false);
expect(trustedEmergencyAccessUsers).toEqual([mockEmergencyAccessMembership.public_key]);
expect(trustedOrgs).toEqual([mockOrganizationMembership.public_key]);
});
it("does not show initial dialog when no organizations or emergency access users exist", async () => {
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
await service.verifyTrust(mockUserId);
expect(KeyRotationTrustInfoComponent.open).not.toHaveBeenCalled();
});
it("shows initial dialog when organizations exist", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([
mockOrganizationMembership,
]);
await service.verifyTrust(mockUserId);
expect(KeyRotationTrustInfoComponent.open).toHaveBeenCalledWith(mockDialogService, {
numberOfEmergencyAccessUsers: 0,
orgName: mockOrganizationMembership.name,
});
});
it("shows initial dialog when emergency access users exist", async () => {
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
mockUserCryptoManagement.get_untrusted_emergency_access_public_keys.mockResolvedValue([
mockEmergencyAccessMembership,
]);
mockUserCryptoManagement.get_untrusted_organization_public_keys.mockResolvedValue([]);
await service.verifyTrust(mockUserId);
expect(KeyRotationTrustInfoComponent.open).toHaveBeenCalledWith(mockDialogService, {
numberOfEmergencyAccessUsers: 1,
orgName: undefined,
});
});
});
});

View File

@@ -0,0 +1,164 @@
import { catchError, EMPTY, firstValueFrom, map } from "rxjs";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
KeyRotationTrustInfoComponent,
} from "@bitwarden/key-management-ui";
import { LogService } from "@bitwarden/logging";
import { RotateUserKeysRequest } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
import {
TrustVerificationResult,
UserKeyRotationService,
} from "./user-key-rotation.service.abstraction";
/**
* Service for rotating user keys using the SDK.
* Handles key rotation and trust verification for organizations and emergency access users.
*/
export class DefaultUserKeyRotationService implements UserKeyRotationService {
constructor(
private sdkService: SdkService,
private logService: LogService,
private dialogService: DialogService,
) {}
async changePasswordAndRotateUserKey(
currentMasterPassword: string,
newMasterPassword: string,
hint: string | undefined,
userId: UserId,
): Promise<void> {
// 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(userId);
if (wasTrustDenied) {
this.logService.info("[Userkey rotation] Trust was denied by user. Aborting!");
return;
}
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
this.logService.info("[UserKey Rotation] Re-encrypting user data with new user key...");
await ref.value.user_crypto_management().rotate_user_keys({
master_key_unlock_method: {
Password: {
old_password: currentMasterPassword,
password: newMasterPassword,
hint: hint,
},
},
trusted_emergency_access_public_keys: trustedEmergencyAccessUserPublicKeys,
trusted_organization_public_keys: trustedOrganizationPublicKeys,
} as RotateUserKeysRequest);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to rotate user keys: ${error}`);
return EMPTY;
}),
),
);
}
async verifyTrust(userId: UserId): Promise<TrustVerificationResult> {
// 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 [emergencyAccessV1Memberships, organizationV1Memberships] = await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const emergencyAccessV1Memberships = await ref.value
.user_crypto_management()
.get_untrusted_emergency_access_public_keys();
const organizationV1Memberships = await ref.value
.user_crypto_management()
.get_untrusted_organization_public_keys();
return [emergencyAccessV1Memberships, organizationV1Memberships] as const;
}),
),
);
this.logService.info("result", { emergencyAccessV1Memberships, organizationV1Memberships });
if (organizationV1Memberships.length > 0 || emergencyAccessV1Memberships.length > 0) {
this.logService.info("[Userkey rotation] Showing trust info dialog...");
const trustInfoDialog = KeyRotationTrustInfoComponent.open(this.dialogService, {
numberOfEmergencyAccessUsers: emergencyAccessV1Memberships.length,
orgName:
organizationV1Memberships.length > 0 ? organizationV1Memberships[0].name : undefined,
});
if (!(await firstValueFrom(trustInfoDialog.closed))) {
return {
wasTrustDenied: true,
trustedOrganizationPublicKeys: [],
trustedEmergencyAccessUserPublicKeys: [],
};
}
}
for (const organization of organizationV1Memberships) {
const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
name: organization.name,
orgId: organization.organization_id as string,
publicKey: Utils.fromB64ToArray(organization.public_key),
});
if (!(await firstValueFrom(dialogRef.closed))) {
return {
wasTrustDenied: true,
trustedOrganizationPublicKeys: [],
trustedEmergencyAccessUserPublicKeys: [],
};
}
}
for (const details of emergencyAccessV1Memberships) {
const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
name: details.name,
userId: details.id as string,
publicKey: Utils.fromB64ToArray(details.public_key),
});
if (!(await firstValueFrom(dialogRef.closed))) {
return {
wasTrustDenied: true,
trustedOrganizationPublicKeys: [],
trustedEmergencyAccessUserPublicKeys: [],
};
}
}
this.logService.info(
"[Userkey rotation] Trust verified for all organizations and emergency access users",
);
return {
wasTrustDenied: false,
trustedOrganizationPublicKeys: organizationV1Memberships.map((d) => d.public_key),
trustedEmergencyAccessUserPublicKeys: emergencyAccessV1Memberships.map((d) => d.public_key),
};
}
}

View File

@@ -0,0 +1 @@
import "core-js/proposals/explicit-resource-management";

View File

@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": ["src/**/*.ts", "src/**/*.js"],
"exclude": ["**/build", "**/dist"]
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base",
"include": ["src", "spec"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

9
package-lock.json generated
View File

@@ -642,6 +642,11 @@
"version": "0.0.0",
"license": "GPL-3.0"
},
"libs/user-crypto-management": {
"name": "@bitwarden/user-crypto-management",
"version": "0.0.1",
"license": "GPL-3.0"
},
"libs/vault": {
"name": "@bitwarden/vault",
"version": "0.0.0",
@@ -5101,6 +5106,10 @@
"resolved": "libs/user-core",
"link": true
},
"node_modules/@bitwarden/user-crypto-management": {
"resolved": "libs/user-crypto-management",
"link": true
},
"node_modules/@bitwarden/vault": {
"resolved": "libs/vault",
"link": true

View File

@@ -33,6 +33,7 @@
"@bitwarden/client-type": ["./libs/client-type/src/index.ts"],
"@bitwarden/common/spec": ["./libs/common/spec"],
"@bitwarden/common/*": ["./libs/common/src/*"],
"@bitwarden/common/spec": ["./libs/common/spec"],
"@bitwarden/components": ["./libs/components/src"],
"@bitwarden/core-test-utils": ["./libs/core-test-utils/src/index.ts"],
"@bitwarden/dirt-card": ["./libs/dirt/card/src"],
@@ -64,6 +65,7 @@
"@bitwarden/ui-common": ["./libs/ui/common/src"],
"@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"],
"@bitwarden/user-core": ["./libs/user-core/src/index.ts"],
"@bitwarden/user-crypto-management": ["./libs/user-crypto-management/src/index.ts"],
"@bitwarden/vault": ["./libs/vault/src"],
"@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"],
"@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"],