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:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
libs/user-crypto-management/README.md
Normal file
5
libs/user-crypto-management/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# user-crypto-management
|
||||
|
||||
Owned by: key-management
|
||||
|
||||
Manage a user's cryptography and cryptographic settings
|
||||
3
libs/user-crypto-management/eslint.config.mjs
Normal file
3
libs/user-crypto-management/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/user-crypto-management/jest.config.js
Normal file
11
libs/user-crypto-management/jest.config.js
Normal 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",
|
||||
};
|
||||
11
libs/user-crypto-management/package.json
Normal file
11
libs/user-crypto-management/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/user-crypto-management",
|
||||
"version": "0.0.1",
|
||||
"description": "Manage a user's cryptography and cryptographic settings",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "key-management"
|
||||
}
|
||||
34
libs/user-crypto-management/project.json
Normal file
34
libs/user-crypto-management/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
libs/user-crypto-management/src/index.ts
Normal file
3
libs/user-crypto-management/src/index.ts
Normal 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";
|
||||
@@ -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 {}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
164
libs/user-crypto-management/src/user-key-rotation.service.ts
Normal file
164
libs/user-crypto-management/src/user-key-rotation.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
1
libs/user-crypto-management/test.setup.ts
Normal file
1
libs/user-crypto-management/test.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "core-js/proposals/explicit-resource-management";
|
||||
6
libs/user-crypto-management/tsconfig.eslint.json
Normal file
6
libs/user-crypto-management/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
5
libs/user-crypto-management/tsconfig.json
Normal file
5
libs/user-crypto-management/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base",
|
||||
"include": ["src", "spec"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
10
libs/user-crypto-management/tsconfig.lib.json
Normal file
10
libs/user-crypto-management/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
10
libs/user-crypto-management/tsconfig.spec.json
Normal file
10
libs/user-crypto-management/tsconfig.spec.json
Normal 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
9
package-lock.json
generated
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user