From bb110122a566ff35fb63ba505ccec6bc14a00f9b Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 20 Feb 2026 15:28:24 +0100 Subject: [PATCH] [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 --- .github/CODEOWNERS | 1 + .../user-key-rotation.service.spec.ts | 12 +- .../key-rotation/user-key-rotation.service.ts | 24 ++ jest.config.js | 1 + libs/common/src/enums/feature-flag.enum.ts | 2 + libs/user-crypto-management/README.md | 5 + libs/user-crypto-management/eslint.config.mjs | 3 + libs/user-crypto-management/jest.config.js | 11 + libs/user-crypto-management/package.json | 11 + libs/user-crypto-management/project.json | 34 ++ libs/user-crypto-management/src/index.ts | 3 + .../src/user-crypto-management.module.ts | 25 ++ .../user-key-rotation.service.abstraction.ts | 41 +++ .../src/user-key-rotation.service.spec.ts | 295 ++++++++++++++++++ .../src/user-key-rotation.service.ts | 164 ++++++++++ libs/user-crypto-management/test.setup.ts | 1 + .../tsconfig.eslint.json | 6 + libs/user-crypto-management/tsconfig.json | 5 + libs/user-crypto-management/tsconfig.lib.json | 10 + .../user-crypto-management/tsconfig.spec.json | 10 + package-lock.json | 9 + tsconfig.base.json | 2 + 22 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 libs/user-crypto-management/README.md create mode 100644 libs/user-crypto-management/eslint.config.mjs create mode 100644 libs/user-crypto-management/jest.config.js create mode 100644 libs/user-crypto-management/package.json create mode 100644 libs/user-crypto-management/project.json create mode 100644 libs/user-crypto-management/src/index.ts create mode 100644 libs/user-crypto-management/src/user-crypto-management.module.ts create mode 100644 libs/user-crypto-management/src/user-key-rotation.service.abstraction.ts create mode 100644 libs/user-crypto-management/src/user-key-rotation.service.spec.ts create mode 100644 libs/user-crypto-management/src/user-key-rotation.service.ts create mode 100644 libs/user-crypto-management/test.setup.ts create mode 100644 libs/user-crypto-management/tsconfig.eslint.json create mode 100644 libs/user-crypto-management/tsconfig.json create mode 100644 libs/user-crypto-management/tsconfig.lib.json create mode 100644 libs/user-crypto-management/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c6c1e42ae52..c2e04d94f95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index a2330025c92..fec972c82f2 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -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; let mockSecurityStateService: MockProxy; let mockMasterPasswordService: MockProxy; + let mockSdkUserKeyRotationService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -348,6 +350,7 @@ describe("KeyRotationService", () => { mockDialogService = mock(); mockCryptoFunctionService = mock(); mockKdfConfigService = mock(); + mockSdkUserKeyRotationService = mock(); mockSdkClientFactory = mock(); mockSdkClientFactory.createSdkClient.mockResolvedValue({ crypto: () => { @@ -358,6 +361,7 @@ describe("KeyRotationService", () => { } as any; }, } as BitwardenClient); + mockSecurityStateService = mock(); mockMasterPasswordService = mock(); @@ -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, diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index 68253a4a35d..26dcacd8f11 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -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 { + 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; diff --git a/jest.config.js b/jest.config.js index bfe447f7a53..5ea699febff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -61,6 +61,7 @@ module.exports = { "/libs/vault/jest.config.js", "/libs/auto-confirm/jest.config.js", "/libs/subscription/jest.config.js", + "/libs/user-crypto-management/jest.config.js", ], // Workaround for a memory leak that crashes tests in CI: diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 7b1013077d7..6fdb146beb8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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, diff --git a/libs/user-crypto-management/README.md b/libs/user-crypto-management/README.md new file mode 100644 index 00000000000..5d348f8f4bb --- /dev/null +++ b/libs/user-crypto-management/README.md @@ -0,0 +1,5 @@ +# user-crypto-management + +Owned by: key-management + +Manage a user's cryptography and cryptographic settings diff --git a/libs/user-crypto-management/eslint.config.mjs b/libs/user-crypto-management/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/user-crypto-management/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/user-crypto-management/jest.config.js b/libs/user-crypto-management/jest.config.js new file mode 100644 index 00000000000..886da6c0940 --- /dev/null +++ b/libs/user-crypto-management/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + displayName: "user-crypto-management", + preset: "../../jest.preset.js", + testEnvironment: "node", + setupFilesAfterEnv: ["/test.setup.ts"], + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/user-crypto-management", +}; diff --git a/libs/user-crypto-management/package.json b/libs/user-crypto-management/package.json new file mode 100644 index 00000000000..d71b90f7cb2 --- /dev/null +++ b/libs/user-crypto-management/package.json @@ -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" +} diff --git a/libs/user-crypto-management/project.json b/libs/user-crypto-management/project.json new file mode 100644 index 00000000000..548fbe55ec3 --- /dev/null +++ b/libs/user-crypto-management/project.json @@ -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" + } + } + } +} diff --git a/libs/user-crypto-management/src/index.ts b/libs/user-crypto-management/src/index.ts new file mode 100644 index 00000000000..cc3cd58300b --- /dev/null +++ b/libs/user-crypto-management/src/index.ts @@ -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"; diff --git a/libs/user-crypto-management/src/user-crypto-management.module.ts b/libs/user-crypto-management/src/user-crypto-management.module.ts new file mode 100644 index 00000000000..8eb59ebd313 --- /dev/null +++ b/libs/user-crypto-management/src/user-crypto-management.module.ts @@ -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 {} diff --git a/libs/user-crypto-management/src/user-key-rotation.service.abstraction.ts b/libs/user-crypto-management/src/user-key-rotation.service.abstraction.ts new file mode 100644 index 00000000000..796af456526 --- /dev/null +++ b/libs/user-crypto-management/src/user-key-rotation.service.abstraction.ts @@ -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; + + /** + * 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; +} diff --git a/libs/user-crypto-management/src/user-key-rotation.service.spec.ts b/libs/user-crypto-management/src/user-key-rotation.service.spec.ts new file mode 100644 index 00000000000..25b99fc979a --- /dev/null +++ b/libs/user-crypto-management/src/user-key-rotation.service.spec.ts @@ -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; + let mockLogService: MockProxy; + let mockDialogService: MockProxy; + + 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(); + mockLogService = mock(); + mockDialogService = mock(); + + 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, + }); + }); + }); +}); diff --git a/libs/user-crypto-management/src/user-key-rotation.service.ts b/libs/user-crypto-management/src/user-key-rotation.service.ts new file mode 100644 index 00000000000..a1af0f7f80e --- /dev/null +++ b/libs/user-crypto-management/src/user-key-rotation.service.ts @@ -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 { + // 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 { + // 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), + }; + } +} diff --git a/libs/user-crypto-management/test.setup.ts b/libs/user-crypto-management/test.setup.ts new file mode 100644 index 00000000000..f0cf585fdd8 --- /dev/null +++ b/libs/user-crypto-management/test.setup.ts @@ -0,0 +1 @@ +import "core-js/proposals/explicit-resource-management"; diff --git a/libs/user-crypto-management/tsconfig.eslint.json b/libs/user-crypto-management/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/user-crypto-management/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/user-crypto-management/tsconfig.json b/libs/user-crypto-management/tsconfig.json new file mode 100644 index 00000000000..9c607a26b09 --- /dev/null +++ b/libs/user-crypto-management/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base", + "include": ["src", "spec"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/user-crypto-management/tsconfig.lib.json b/libs/user-crypto-management/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/user-crypto-management/tsconfig.lib.json @@ -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"] +} diff --git a/libs/user-crypto-management/tsconfig.spec.json b/libs/user-crypto-management/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/user-crypto-management/tsconfig.spec.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index 9f6e82d98ef..f5ac6ccbc0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 diff --git a/tsconfig.base.json b/tsconfig.base.json index 995eac031fd..fb76ea752a7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],