From a62f8cd6522eec49d84d0f7c18cf315a8603edac Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Fri, 22 Dec 2023 10:31:24 -0500 Subject: [PATCH] [PM-3797] Client changes to use new key rotation process (#6881) ## Type of change ``` - [ ] Bug fix - [ ] New feature development - [x] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective Final Client changes for Key Rotation Improvements. - Introduces a new `KeyRotationService` that is responsible for owning rotation process. - Moves `Send` re-encryption to the `SendService` (`KeyRotationService` shouldn't have knowledge about how domains are encrypted). - Moves `EmergencyAccess` re-encryption to the `EmergencyAccessService`. - Renames `AccountRecoveryService` to `OrganizationUserResetPasswordService` after feedback from Admin Console ## Code changes Auth - **emergency-access-update.request.ts:** New request model for domain updates that includes Id - **emergency-access.service.ts:** Moved `EmergencyAccess` re-encryption to the `EmergencyAccessService`. Add deprecated method for legacy key rotations if feature flag is off - **key-rotation.service/api/spec/module:** New key rotation service for owning the rotation process. Added api service, module, and spec file. - **update-key.request.ts:** Moved to Auth ownership. Also added new properties for including other domains. - **migrate-legacy-encryption.component.ts:** Use new key rotation service instead of old component specific service. Delete old service. - **change-password.component.ts:** Use new key rotation service. - **settings.module.ts:** Import key rotation module. Admin Console - **organization-user-reset-password.service.ts/spec:** Responsible for re-encryption of reset password keys during key rotation. Added tests. - **organization-user-reset-password-enrollment.request.ts:** New request model for key rotations - **reset-password.component.ts:** Update `AccountRecoveryService` to `OrganizationUserResetPasswordService` - **enroll-master-password-reset.component.ts:** Update `AccountRecoveryService` to `OrganizationUserResetPasswordService` Tools - **send.service/spec.ts:** Responsible only for re-encryption of sends during key rotation. Added tests. Other - **api.service.ts:** Move `postAccountKey` to `KeyRotationApiService` - **feature-flag.enum.ts:** add new feature flag ## Screenshots ## Before you submit - Please add **unit tests** where it makes sense to do so (encouraged but not required) - If this change requires a **documentation update** - notify the documentation team - If this change has particular **deployment requirements** - notify the DevOps team - Ensure that all UI additions follow [WCAG AA requirements](https://contributing.bitwarden.com/contributing/accessibility/) --- .../components/reset-password.component.ts | 6 +- ...ation-user-reset-password.service.spec.ts} | 33 +-- ...ganization-user-reset-password.service.ts} | 64 +++-- .../enroll-master-password-reset.component.ts | 6 +- .../emergency-access-update.request.ts | 4 + .../services/emergency-access.service.spec.ts | 25 +- .../services/emergency-access.service.ts | 34 ++- .../request/update-key.request.ts | 17 ++ .../user-key-rotation-api.service.ts | 14 ++ .../key-rotation/user-key-rotation.module.ts | 9 + .../user-key-rotation.service.spec.ts | 215 +++++++++++++++++ .../key-rotation/user-key-rotation.service.ts | 143 +++++++++++ .../migrate-legacy-encryption.component.ts | 27 +-- .../migrate-legacy-encryption.service.spec.ts | 226 ------------------ .../migrate-legacy-encryption.service.ts | 163 ------------- .../settings/change-password.component.ts | 104 +------- .../src/app/auth/settings/settings.module.ts | 2 + libs/common/src/abstractions/api.service.ts | 2 - ...-user-reset-password-enrollment.request.ts | 4 + libs/common/src/enums/feature-flag.enum.ts | 1 + .../src/models/request/update-key.request.ts | 12 - libs/common/src/services/api.service.ts | 5 - .../send/services/send.service.abstraction.ts | 10 +- .../tools/send/services/send.service.spec.ts | 32 +++ .../src/tools/send/services/send.service.ts | 19 +- 25 files changed, 569 insertions(+), 608 deletions(-) rename apps/web/src/app/admin-console/organizations/members/services/{account-recovery/account-recovery.service.spec.ts => organization-user-reset-password/organization-user-reset-password.service.spec.ts} (88%) rename apps/web/src/app/admin-console/organizations/members/services/{account-recovery/account-recovery.service.ts => organization-user-reset-password/organization-user-reset-password.service.ts} (76%) create mode 100644 apps/web/src/app/auth/key-rotation/request/update-key.request.ts create mode 100644 apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts create mode 100644 apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts create mode 100644 apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts create mode 100644 apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts delete mode 100644 apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.spec.ts delete mode 100644 apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.ts delete mode 100644 libs/common/src/models/request/update-key.request.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts index 28456b854ed..641d1f2e954 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts @@ -20,7 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { DialogService } from "@bitwarden/components"; -import { AccountRecoveryService } from "../services/account-recovery/account-recovery.service"; +import { OrganizationUserResetPasswordService } from "../services/organization-user-reset-password/organization-user-reset-password.service"; @Component({ selector: "app-reset-password", @@ -43,7 +43,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor( - private accountRecoveryService: AccountRecoveryService, + private resetPasswordService: OrganizationUserResetPasswordService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private passwordGenerationService: PasswordGenerationServiceAbstraction, @@ -144,7 +144,7 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { } try { - this.formPromise = this.accountRecoveryService.resetMasterPassword( + this.formPromise = this.resetPasswordService.resetMasterPassword( this.newPassword, this.email, this.id, diff --git a/apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts similarity index 88% rename from apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.spec.ts rename to apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts index 161c6020767..710e7be79f5 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts @@ -19,10 +19,10 @@ import { } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { AccountRecoveryService } from "./account-recovery.service"; +import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service"; -describe("AccountRecoveryService", () => { - let sut: AccountRecoveryService; +describe("OrganizationUserResetPasswordService", () => { + let sut: OrganizationUserResetPasswordService; let cryptoService: MockProxy; let encryptService: MockProxy; @@ -39,7 +39,7 @@ describe("AccountRecoveryService", () => { organizationApiService = mock(); i18nService = mock(); - sut = new AccountRecoveryService( + sut = new OrganizationUserResetPasswordService( cryptoService, encryptService, organizationService, @@ -161,7 +161,7 @@ describe("AccountRecoveryService", () => { }); }); - describe("rotate", () => { + describe("getRotatedKeys", () => { beforeEach(() => { organizationService.getAll.mockResolvedValue([ createOrganization("1", "org1"), @@ -178,29 +178,12 @@ describe("AccountRecoveryService", () => { ); }); - it("should rotate all of the user's recovery key", async () => { - organizationApiService.getKeys.mockResolvedValue( - new OrganizationKeysResponse({ - privateKey: "test-private-key", - publicKey: "test-public-key", - }), - ); - cryptoService.rsaEncrypt.mockResolvedValue( - new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"), - ); - organizationService.getAll.mockResolvedValue([ - createOrganization("1", "org1"), - createOrganization("2", "org2"), - ]); - - await sut.rotate( + it("should return all re-encrypted account recovery keys", async () => { + const result = await sut.getRotatedKeys( new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, - "test-master-password-hash", ); - expect( - organizationUserService.putOrganizationUserResetPasswordEnrollment, - ).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts similarity index 76% rename from apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.ts rename to apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index ade098b4df2..3c13d23f0a8 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/account-recovery/account-recovery.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -4,8 +4,8 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { - OrganizationUserResetPasswordEnrollmentRequest, OrganizationUserResetPasswordRequest, + OrganizationUserResetPasswordWithIdRequest, } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -21,7 +21,7 @@ import { @Injectable({ providedIn: "root", }) -export class AccountRecoveryService { +export class OrganizationUserResetPasswordService { constructor( private cryptoService: CryptoService, private encryptService: EncryptService, @@ -120,36 +120,60 @@ export class AccountRecoveryService { } /** - * Rotates the user's recovery key for all enrolled organizations. + * Returns existing account recovery keys re-encrypted with the new user key. * @param newUserKey the new user key - * @param masterPasswordHash the user's master password hash (required for user verification) + * @throws Error if new user key is null */ - async rotate(newUserKey: UserKey, masterPasswordHash: string): Promise { + async getRotatedKeys( + newUserKey: UserKey, + ): Promise { + if (newUserKey == null) { + throw new Error("New user key is required for rotation."); + } + const allOrgs = await this.organizationService.getAll(); + if (!allOrgs) { + return; + } + + const requests: OrganizationUserResetPasswordWithIdRequest[] = []; for (const org of allOrgs) { // If not already enrolled, skip if (!org.resetPasswordEnrolled) { continue; } - try { - // Re-enroll - encrypt user key with organization public key - const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey); + // Re-enroll - encrypt user key with organization public key + const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey); - // Create/Execute request - const request = new OrganizationUserResetPasswordEnrollmentRequest(); - request.resetPasswordKey = encryptedKey; - request.masterPasswordHash = masterPasswordHash; + // Create/Execute request + const request = new OrganizationUserResetPasswordWithIdRequest(); + request.organizationId = org.id; + request.resetPasswordKey = encryptedKey; + request.masterPasswordHash = "ignored"; - await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( - org.id, - org.userId, - request, - ); - } catch (e) { - // If enrollment fails, continue to next org - } + requests.push(request); + } + return requests; + } + + /** + * @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data. + */ + async postLegacyRotation( + userId: string, + requests: OrganizationUserResetPasswordWithIdRequest[], + ): Promise { + if (requests == null) { + return; + } + for (const request of requests) { + await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( + request.organizationId, + userId, + request, + ); } } } diff --git a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts index 0bb91846534..4cbdbf3864c 100644 --- a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts +++ b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts @@ -13,7 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; -import { AccountRecoveryService } from "../members/services/account-recovery/account-recovery.service"; +import { OrganizationUserResetPasswordService } from "../members/services/organization-user-reset-password/organization-user-reset-password.service"; interface EnrollMasterPasswordResetData { organization: Organization; @@ -33,7 +33,7 @@ export class EnrollMasterPasswordReset { constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) protected data: EnrollMasterPasswordResetData, - private accountRecoveryService: AccountRecoveryService, + private resetPasswordService: OrganizationUserResetPasswordService, private userVerificationService: UserVerificationService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, @@ -53,7 +53,7 @@ export class EnrollMasterPasswordReset { ) .then(async (request) => { // Create request and execute enrollment - request.resetPasswordKey = await this.accountRecoveryService.buildRecoveryKey( + request.resetPasswordKey = await this.resetPasswordService.buildRecoveryKey( this.organization.id, ); await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( diff --git a/apps/web/src/app/auth/emergency-access/request/emergency-access-update.request.ts b/apps/web/src/app/auth/emergency-access/request/emergency-access-update.request.ts index b2fc2265641..51f2c80b6f5 100644 --- a/apps/web/src/app/auth/emergency-access/request/emergency-access-update.request.ts +++ b/apps/web/src/app/auth/emergency-access/request/emergency-access-update.request.ts @@ -5,3 +5,7 @@ export class EmergencyAccessUpdateRequest { waitTimeDays: number; keyEncrypted?: string; } + +export class EmergencyAccessWithIdRequest extends EmergencyAccessUpdateRequest { + id: string; +} diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index 741ff572405..d1143f7b06d 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -20,7 +20,6 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; import { EmergencyAccessPasswordRequest } from "../request/emergency-access-password.request"; -import { EmergencyAccessUpdateRequest } from "../request/emergency-access-update.request"; import { EmergencyAccessGranteeDetailsResponse, EmergencyAccessTakeoverResponse, @@ -210,7 +209,7 @@ describe("EmergencyAccessService", () => { }); }); - describe("rotate", () => { + describe("getRotatedKeys", () => { let mockUserKey: UserKey; const allowedStatuses = [ EmergencyAccessStatusType.Confirmed, @@ -245,26 +244,10 @@ describe("EmergencyAccessService", () => { }); }); - it("Only updates emergency accesses with allowed statuses", async () => { - await emergencyAccessService.rotate(mockUserKey); + it("Only returns emergency accesses with allowed statuses", async () => { + const result = await emergencyAccessService.getRotatedKeys(mockUserKey); - let expectedCallCount = 0; - - mockEmergencyAccess.data.forEach((emergencyAccess) => { - if (allowedStatuses.includes(emergencyAccess.status)) { - expect(emergencyAccessApiService.putEmergencyAccess).toHaveBeenCalledWith( - emergencyAccess.id, - expect.any(EmergencyAccessUpdateRequest), - ); - expectedCallCount++; - } else { - expect(emergencyAccessApiService.putEmergencyAccess).not.toHaveBeenCalledWith( - emergencyAccess.id, - expect.any(EmergencyAccessUpdateRequest), - ); - } - }); - expect(emergencyAccessApiService.putEmergencyAccess).toHaveBeenCalledTimes(expectedCallCount); + expect(result).toHaveLength(allowedStatuses.length); }); }); }); diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 9a093e8d5c3..2ccf4691fc3 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -24,7 +24,10 @@ import { EmergencyAccessAcceptRequest } from "../request/emergency-access-accept import { EmergencyAccessConfirmRequest } from "../request/emergency-access-confirm.request"; import { EmergencyAccessInviteRequest } from "../request/emergency-access-invite.request"; import { EmergencyAccessPasswordRequest } from "../request/emergency-access-password.request"; -import { EmergencyAccessUpdateRequest } from "../request/emergency-access-update.request"; +import { + EmergencyAccessUpdateRequest, + EmergencyAccessWithIdRequest, +} from "../request/emergency-access-update.request"; import { EmergencyAccessApiService } from "./emergency-access-api.service"; @@ -252,13 +255,19 @@ export class EmergencyAccessService { } /** - * Rotates the user key for all existing emergency access. + * Returns existing emergency access keys re-encrypted with new user key. * Intended for grantor. * @param newUserKey the new user key */ - async rotate(newUserKey: UserKey): Promise { + async getRotatedKeys(newUserKey: UserKey): Promise { + const requests: EmergencyAccessWithIdRequest[] = []; const existingEmergencyAccess = await this.emergencyAccessApiService.getEmergencyAccessTrusted(); + + if (!existingEmergencyAccess || existingEmergencyAccess.data.length === 0) { + return requests; + } + // Any Invited or Accepted requests won't have the key yet, so we don't need to update them const allowedStatuses = new Set([ EmergencyAccessStatusType.Confirmed, @@ -277,16 +286,29 @@ export class EmergencyAccessService { // Encrypt new user key with public key const encryptedKey = await this.encryptKey(newUserKey, publicKey); - const updateRequest = new EmergencyAccessUpdateRequest(); + const updateRequest = new EmergencyAccessWithIdRequest(); + updateRequest.id = details.id; updateRequest.type = details.type; updateRequest.waitTimeDays = details.waitTimeDays; updateRequest.keyEncrypted = encryptedKey; - - await this.emergencyAccessApiService.putEmergencyAccess(details.id, updateRequest); + requests.push(updateRequest); } + return requests; } private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise { return (await this.cryptoService.rsaEncrypt(userKey.key, publicKey)).encryptedString; } + + /** + * @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data. + */ + async postLegacyRotation(requests: EmergencyAccessWithIdRequest[]): Promise { + if (requests == null) { + return; + } + for (const request of requests) { + await this.emergencyAccessApiService.putEmergencyAccess(request.id, request); + } + } } diff --git a/apps/web/src/app/auth/key-rotation/request/update-key.request.ts b/apps/web/src/app/auth/key-rotation/request/update-key.request.ts new file mode 100644 index 00000000000..f8637110e78 --- /dev/null +++ b/apps/web/src/app/auth/key-rotation/request/update-key.request.ts @@ -0,0 +1,17 @@ +import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; +import { SendWithIdRequest } from "@bitwarden/common/src/tools/send/models/request/send-with-id.request"; +import { CipherWithIdRequest } from "@bitwarden/common/src/vault/models/request/cipher-with-id.request"; +import { FolderWithIdRequest } from "@bitwarden/common/src/vault/models/request/folder-with-id.request"; + +import { EmergencyAccessWithIdRequest } from "../../emergency-access/request/emergency-access-update.request"; + +export class UpdateKeyRequest { + masterPasswordHash: string; + key: string; + privateKey: string; + ciphers: CipherWithIdRequest[] = []; + folders: FolderWithIdRequest[] = []; + sends: SendWithIdRequest[] = []; + emergencyAccessKeys: EmergencyAccessWithIdRequest[] = []; + resetPasswordKeys: OrganizationUserResetPasswordWithIdRequest[] = []; +} diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts new file mode 100644 index 00000000000..3c8adc886df --- /dev/null +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts @@ -0,0 +1,14 @@ +import { inject, Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { UpdateKeyRequest } from "./request/update-key.request"; + +@Injectable() +export class UserKeyRotationApiService { + readonly apiService = inject(ApiService); + + postUserKeyUpdate(request: UpdateKeyRequest): Promise { + return this.apiService.send("POST", "/accounts/key", request, true, false); + } +} diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts new file mode 100644 index 00000000000..58421390e7d --- /dev/null +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; +import { UserKeyRotationService } from "./user-key-rotation.service"; + +@NgModule({ + providers: [UserKeyRotationService, UserKeyRotationApiService], +}) +export class UserKeyRotationModule {} diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts new file mode 100644 index 00000000000..32d84ad8ba5 --- /dev/null +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -0,0 +1,215 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { + SymmetricCryptoKey, + UserKey, +} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; + +import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; +import { StateService } from "../../core"; +import { EmergencyAccessService } from "../emergency-access"; + +import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; +import { UserKeyRotationService } from "./user-key-rotation.service"; + +describe("KeyRotationService", () => { + let keyRotationService: UserKeyRotationService; + + let mockApiService: MockProxy; + let mockCipherService: MockProxy; + let mockFolderService: MockProxy; + let mockSendService: MockProxy; + let mockEmergencyAccessService: MockProxy; + let mockResetPasswordService: MockProxy; + let mockDeviceTrustCryptoService: MockProxy; + let mockCryptoService: MockProxy; + let mockEncryptService: MockProxy; + let mockStateService: MockProxy; + let mockConfigService: MockProxy; + + beforeAll(() => { + mockApiService = mock(); + mockCipherService = mock(); + mockFolderService = mock(); + mockSendService = mock(); + mockEmergencyAccessService = mock(); + mockResetPasswordService = mock(); + mockDeviceTrustCryptoService = mock(); + mockCryptoService = mock(); + mockEncryptService = mock(); + mockStateService = mock(); + mockConfigService = mock(); + + keyRotationService = new UserKeyRotationService( + mockApiService, + mockCipherService, + mockFolderService, + mockSendService, + mockEmergencyAccessService, + mockResetPasswordService, + mockDeviceTrustCryptoService, + mockCryptoService, + mockEncryptService, + mockStateService, + mockConfigService, + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("instantiates", () => { + expect(keyRotationService).not.toBeFalsy(); + }); + + describe("rotateUserKeyAndEncryptedData", () => { + let folderViews: BehaviorSubject; + let sends: BehaviorSubject; + + beforeAll(() => { + mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any); + mockCryptoService.makeUserKey.mockResolvedValue([ + new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, + { + encryptedString: "mockEncryptedUserKey", + } as any, + ]); + mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + // Mock private key + mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any); + + // Mock ciphers + const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")]; + mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers); + + // Mock folders + const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")]; + folderViews = new BehaviorSubject(mockFolders); + mockFolderService.folderViews$ = folderViews; + + // Mock sends + const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")]; + sends = new BehaviorSubject(mockSends); + mockSendService.sends$ = sends; + + // Mock encryption methods + mockEncryptService.encrypt.mockResolvedValue({ + encryptedString: "mockEncryptedData", + } as any); + + mockFolderService.encrypt.mockImplementation((folder, userKey) => { + const encryptedFolder = new Folder(); + encryptedFolder.id = folder.id; + encryptedFolder.name = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "Encrypted: " + folder.name, + ); + return Promise.resolve(encryptedFolder); + }); + + mockCipherService.encrypt.mockImplementation((cipher, userKey) => { + const encryptedCipher = new Cipher(); + encryptedCipher.id = cipher.id; + encryptedCipher.name = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "Encrypted: " + cipher.name, + ); + return Promise.resolve(encryptedCipher); + }); + }); + + it("rotates the user key and encrypted data", async () => { + await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); + + expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled(); + const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0]; + expect(arg.ciphers.length).toBe(2); + expect(arg.folders.length).toBe(2); + }); + + it("throws if master password provided is falsey", async () => { + await expect(keyRotationService.rotateUserKeyAndEncryptedData("")).rejects.toThrow(); + }); + + it("throws if master key creation fails", async () => { + mockCryptoService.makeMasterKey.mockResolvedValueOnce(null); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + ).rejects.toThrow(); + }); + + it("throws if user key creation fails", async () => { + mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + ).rejects.toThrow(); + }); + + it("saves the master key in state after creation", async () => { + await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); + + expect(mockCryptoService.setMasterKey).toHaveBeenCalledWith("mockMasterKey" as any); + }); + + it("uses legacy rotation if feature flag is off", async () => { + mockConfigService.getFeatureFlag.mockResolvedValueOnce(false); + + await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); + + expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled(); + expect(mockEmergencyAccessService.postLegacyRotation).toHaveBeenCalled(); + expect(mockResetPasswordService.postLegacyRotation).toHaveBeenCalled(); + }); + + it("throws if server rotation fails", async () => { + mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + ).rejects.toThrow(); + }); + }); +}); + +function createMockFolder(id: string, name: string): FolderView { + const folder = new FolderView(); + folder.id = id; + folder.name = name; + return folder; +} + +function createMockCipher(id: string, name: string): CipherView { + const cipher = new CipherView(); + cipher.id = id; + cipher.name = name; + cipher.type = CipherType.Login; + return cipher; +} + +function createMockSend(id: string, name: string): Send { + const send = new Send(); + send.id = id; + send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name); + return send; +} diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts new file mode 100644 index 00000000000..5601f784280 --- /dev/null +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; +import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; + +import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; +import { EmergencyAccessService } from "../emergency-access"; + +import { UpdateKeyRequest } from "./request/update-key.request"; +import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; + +@Injectable() +export class UserKeyRotationService { + constructor( + private apiService: UserKeyRotationApiService, + private cipherService: CipherService, + private folderService: FolderService, + private sendService: SendService, + private emergencyAccessService: EmergencyAccessService, + private resetPasswordService: OrganizationUserResetPasswordService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private cryptoService: CryptoService, + private encryptService: EncryptService, + private stateService: StateService, + private configService: ConfigServiceAbstraction, + ) {} + + /** + * Creates a new user key and re-encrypts all required data with the it. + * @param masterPassword current master password (used for validation) + */ + async rotateUserKeyAndEncryptedData(masterPassword: string): Promise { + if (!masterPassword) { + throw new Error("Invalid master password"); + } + + // Create master key to validate the master password + const masterKey = await this.cryptoService.makeMasterKey( + masterPassword, + await this.stateService.getEmail(), + await this.stateService.getKdfType(), + await this.stateService.getKdfConfig(), + ); + + if (!masterKey) { + throw new Error("Master key could not be created"); + } + + // Set master key again in case it was lost (could be lost on refresh) + await this.cryptoService.setMasterKey(masterKey); + const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey); + + if (!newUserKey || !newEncUserKey) { + throw new Error("User key could not be created"); + } + + // Create new request + const request = new UpdateKeyRequest(); + + // Add new user key + request.key = newEncUserKey.encryptedString; + + // Add master key hash + const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey); + request.masterPasswordHash = masterPasswordHash; + + // Add re-encrypted data + request.privateKey = await this.encryptPrivateKey(newUserKey); + request.ciphers = await this.encryptCiphers(newUserKey); + request.folders = await this.encryptFolders(newUserKey); + request.sends = await this.sendService.getRotatedKeys(newUserKey); + request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey); + request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey); + + if (await this.configService.getFeatureFlag(FeatureFlag.KeyRotationImprovements)) { + await this.apiService.postUserKeyUpdate(request); + } else { + await this.rotateUserKeyAndEncryptedDataLegacy(request); + } + + await this.deviceTrustCryptoService.rotateDevicesTrust(newUserKey, masterPasswordHash); + } + + private async encryptPrivateKey(newUserKey: UserKey): Promise { + const privateKey = await this.cryptoService.getPrivateKey(); + if (!privateKey) { + return; + } + return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString; + } + + private async encryptCiphers(newUserKey: UserKey): Promise { + const ciphers = await this.cipherService.getAllDecrypted(); + if (!ciphers) { + // Must return an empty array for backwards compatibility + return []; + } + return await Promise.all( + ciphers.map(async (cipher) => { + const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey); + return new CipherWithIdRequest(encryptedCipher); + }), + ); + } + + private async encryptFolders(newUserKey: UserKey): Promise { + const folders = await firstValueFrom(this.folderService.folderViews$); + if (!folders) { + // Must return an empty array for backwards compatibility + return []; + } + return await Promise.all( + folders.map(async (folder) => { + const encryptedFolder = await this.folderService.encrypt(folder, newUserKey); + return new FolderWithIdRequest(encryptedFolder); + }), + ); + } + + private async rotateUserKeyAndEncryptedDataLegacy(request: UpdateKeyRequest): Promise { + // Update keys, ciphers, folders, and sends + await this.apiService.postUserKeyUpdate(request); + + // Update emergency access keys + await this.emergencyAccessService.postLegacyRotation(request.emergencyAccessKeys); + + // Update account recovery keys + const userId = await this.stateService.getUserId(); + await this.resetPasswordService.postLegacyRotation(userId, request.resetPasswordKeys); + } +} diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts index f2cfd17c082..d76a3c2116d 100644 --- a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts @@ -8,16 +8,14 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SharedModule } from "../../shared"; -import { EmergencyAccessModule } from "../emergency-access"; - -import { MigrateFromLegacyEncryptionService } from "./migrate-legacy-encryption.service"; +import { UserKeyRotationModule } from "../key-rotation/user-key-rotation.module"; +import { UserKeyRotationService } from "../key-rotation/user-key-rotation.service"; // The master key was originally used to encrypt user data, before the user key was introduced. // This component is used to migrate from the old encryption scheme to the new one. @Component({ standalone: true, - imports: [SharedModule, EmergencyAccessModule], - providers: [MigrateFromLegacyEncryptionService], + imports: [SharedModule, UserKeyRotationModule], templateUrl: "migrate-legacy-encryption.component.html", }) export class MigrateFromLegacyEncryptionComponent { @@ -26,9 +24,9 @@ export class MigrateFromLegacyEncryptionComponent { }); constructor( + private keyRotationService: UserKeyRotationService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private migrationService: MigrateFromLegacyEncryptionService, private cryptoService: CryptoService, private messagingService: MessagingService, private logService: LogService, @@ -50,22 +48,7 @@ export class MigrateFromLegacyEncryptionComponent { const masterPassword = this.formGroup.value.masterPassword; try { - // Create new user key - const [newUserKey, masterKeyEncUserKey] = - await this.migrationService.createNewUserKey(masterPassword); - - // Update admin recover keys - await this.migrationService.updateAllAdminRecoveryKeys(masterPassword, newUserKey); - - // Update emergency access - await this.migrationService.updateEmergencyAccesses(newUserKey); - - // Update keys, folders, ciphers, and sends - await this.migrationService.updateKeysAndEncryptedData( - masterPassword, - newUserKey, - masterKeyEncUserKey, - ); + await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword); this.platformUtilsService.showToast( "success", diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.spec.ts b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.spec.ts deleted file mode 100644 index 26b1e44f995..00000000000 --- a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums"; -import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { - MasterKey, - SymmetricCryptoKey, - UserKey, -} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { Send } from "@bitwarden/common/tools/send/models/domain/send"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { Folder } from "@bitwarden/common/vault/models/domain/folder"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; - -import { AccountRecoveryService } from "../../admin-console/organizations/members/services/account-recovery/account-recovery.service"; -import { EmergencyAccessService } from "../emergency-access"; - -import { MigrateFromLegacyEncryptionService } from "./migrate-legacy-encryption.service"; - -describe("migrateFromLegacyEncryptionService", () => { - let migrateFromLegacyEncryptionService: MigrateFromLegacyEncryptionService; - - const emergencyAccessService = mock(); - const accountRecoveryService = mock(); - const apiService = mock(); - const encryptService = mock(); - const cryptoService = mock(); - const syncService = mock(); - const cipherService = mock(); - const folderService = mock(); - const sendService = mock(); - const stateService = mock(); - let folderViews: BehaviorSubject; - let sends: BehaviorSubject; - - beforeEach(() => { - jest.clearAllMocks(); - - migrateFromLegacyEncryptionService = new MigrateFromLegacyEncryptionService( - emergencyAccessService, - accountRecoveryService, - apiService, - cryptoService, - encryptService, - syncService, - cipherService, - folderService, - sendService, - stateService, - ); - }); - - it("instantiates", () => { - expect(migrateFromLegacyEncryptionService).not.toBeFalsy(); - }); - - describe("createNewUserKey", () => { - it("validates master password and legacy user", async () => { - const mockMasterPassword = "mockMasterPassword"; - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - const mockMasterKey = new SymmetricCryptoKey(mockRandomBytes) as MasterKey; - stateService.getEmail.mockResolvedValue("mockEmail"); - stateService.getKdfType.mockResolvedValue(KdfType.PBKDF2_SHA256); - stateService.getKdfConfig.mockResolvedValue({ iterations: 100000 }); - cryptoService.makeMasterKey.mockResolvedValue(mockMasterKey); - cryptoService.isLegacyUser.mockResolvedValue(false); - - await expect( - migrateFromLegacyEncryptionService.createNewUserKey(mockMasterPassword), - ).rejects.toThrowError("Invalid master password or user may not be legacy"); - }); - }); - - describe("updateKeysAndEncryptedData", () => { - let mockMasterPassword: string; - let mockUserKey: UserKey; - let mockEncUserKey: EncString; - - beforeEach(() => { - mockMasterPassword = "mockMasterPassword"; - - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; - mockEncUserKey = new EncString("mockEncUserKey"); - - const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")]; - const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")]; - const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")]; - - cryptoService.getPrivateKey.mockResolvedValue(new Uint8Array(64) as CsprngArray); - cryptoService.rsaEncrypt.mockResolvedValue( - new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "Encrypted"), - ); - - folderViews = new BehaviorSubject(mockFolders); - folderService.folderViews$ = folderViews; - - cipherService.getAllDecrypted.mockResolvedValue(mockCiphers); - - sends = new BehaviorSubject(mockSends); - sendService.sends$ = sends; - - encryptService.encrypt.mockImplementation((plainValue, userKey) => { - return Promise.resolve( - new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "Encrypted: " + plainValue), - ); - }); - - folderService.encrypt.mockImplementation((folder, userKey) => { - const encryptedFolder = new Folder(); - encryptedFolder.id = folder.id; - encryptedFolder.name = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "Encrypted: " + folder.name, - ); - return Promise.resolve(encryptedFolder); - }); - - cipherService.encrypt.mockImplementation((cipher, userKey) => { - const encryptedCipher = new Cipher(); - encryptedCipher.id = cipher.id; - encryptedCipher.name = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "Encrypted: " + cipher.name, - ); - return Promise.resolve(encryptedCipher); - }); - }); - - it("derives the master key in case it hasn't been set", async () => { - await migrateFromLegacyEncryptionService.updateKeysAndEncryptedData( - mockMasterPassword, - mockUserKey, - mockEncUserKey, - ); - - expect(cryptoService.getOrDeriveMasterKey).toHaveBeenCalled(); - }); - - it("syncs latest data", async () => { - await migrateFromLegacyEncryptionService.updateKeysAndEncryptedData( - mockMasterPassword, - mockUserKey, - mockEncUserKey, - ); - expect(syncService.fullSync).toHaveBeenCalledWith(true); - }); - - it("does not post new account data if sync fails", async () => { - syncService.fullSync.mockRejectedValueOnce(new Error("sync failed")); - - await expect( - migrateFromLegacyEncryptionService.updateKeysAndEncryptedData( - mockMasterPassword, - mockUserKey, - mockEncUserKey, - ), - ).rejects.toThrowError("sync failed"); - - expect(apiService.postAccountKey).not.toHaveBeenCalled(); - }); - - it("does not post new account data if data retrieval fails", async () => { - (migrateFromLegacyEncryptionService as any).encryptCiphers = async () => { - throw new Error("Ciphers failed to be retrieved"); - }; - - await expect( - migrateFromLegacyEncryptionService.updateKeysAndEncryptedData( - mockMasterPassword, - mockUserKey, - mockEncUserKey, - ), - ).rejects.toThrowError("Ciphers failed to be retrieved"); - - expect(apiService.postAccountKey).not.toHaveBeenCalled(); - }); - }); - - describe("updateEmergencyAccesses", () => { - let mockUserKey: UserKey; - - beforeEach(() => { - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; - }); - - it("Uses emergency access service to rotate", async () => { - await migrateFromLegacyEncryptionService.updateEmergencyAccesses(mockUserKey); - - expect(emergencyAccessService.rotate).toHaveBeenCalled(); - }); - }); -}); - -function createMockFolder(id: string, name: string): FolderView { - const folder = new FolderView(); - folder.id = id; - folder.name = name; - return folder; -} - -function createMockCipher(id: string, name: string): CipherView { - const cipher = new CipherView(); - cipher.id = id; - cipher.name = name; - return cipher; -} - -function createMockSend(id: string, name: string): Send { - const send = new Send(); - send.id = id; - send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name); - return send; -} diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.ts b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.ts deleted file mode 100644 index 31d5e420272..00000000000 --- a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.service.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { UpdateKeyRequest } from "@bitwarden/common/models/request/update-key.request"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; -import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; - -import { AccountRecoveryService } from "../../admin-console/organizations/members/services/account-recovery/account-recovery.service"; -import { EmergencyAccessService } from "../emergency-access"; - -// TODO: PM-3797 - This service should be expanded and used for user key rotations in change-password.component.ts -@Injectable() -export class MigrateFromLegacyEncryptionService { - constructor( - private emergencyAccessService: EmergencyAccessService, - private accountRecoveryService: AccountRecoveryService, - private apiService: ApiService, - private cryptoService: CryptoService, - private encryptService: EncryptService, - private syncService: SyncService, - private cipherService: CipherService, - private folderService: FolderService, - private sendService: SendService, - private stateService: StateService, - ) {} - - /** - * Validates the master password and creates a new user key. - * @returns A new user key along with the encrypted version - */ - async createNewUserKey(masterPassword: string): Promise<[UserKey, EncString]> { - // Create master key to validate the master password - const masterKey = await this.cryptoService.makeMasterKey( - masterPassword, - await this.stateService.getEmail(), - await this.stateService.getKdfType(), - await this.stateService.getKdfConfig(), - ); - - if (!masterKey) { - throw new Error("Invalid master password"); - } - - if (!(await this.cryptoService.isLegacyUser(masterKey))) { - throw new Error("Invalid master password or user may not be legacy"); - } - - // Set master key again in case it was lost (could be lost on refresh) - await this.cryptoService.setMasterKey(masterKey); - return await this.cryptoService.makeUserKey(masterKey); - } - - /** - * Updates the user key, master key hash, private key, folders, ciphers, and sends - * on the server. - * @param masterPassword The master password - * @param newUserKey The new user key - * @param newEncUserKey The new encrypted user key - */ - async updateKeysAndEncryptedData( - masterPassword: string, - newUserKey: UserKey, - newEncUserKey: EncString, - ): Promise { - // Create new request and add master key and hash - const request = new UpdateKeyRequest(); - request.key = newEncUserKey.encryptedString; - request.masterPasswordHash = await this.cryptoService.hashMasterKey( - masterPassword, - await this.cryptoService.getOrDeriveMasterKey(masterPassword), - ); - - // Sync before encrypting to make sure we have latest data - await this.syncService.fullSync(true); - - request.privateKey = await this.encryptPrivateKey(newUserKey); - request.folders = await this.encryptFolders(newUserKey); - request.ciphers = await this.encryptCiphers(newUserKey); - request.sends = await this.encryptSends(newUserKey); - - return this.apiService.postAccountKey(request); - } - - /** - * Gets user's emergency access details from server and encrypts with new user key - * on the server. - * @param newUserKey The new user key - */ - updateEmergencyAccesses(newUserKey: UserKey) { - return this.emergencyAccessService.rotate(newUserKey); - } - - /** Updates all admin recovery keys on the server with the new user key - * @param masterPassword The user's master password - * @param newUserKey The new user key - */ - async updateAllAdminRecoveryKeys(masterPassword: string, newUserKey: UserKey) { - const masterPasswordHash = await this.cryptoService.hashMasterKey( - masterPassword, - await this.cryptoService.getOrDeriveMasterKey(masterPassword), - ); - await this.accountRecoveryService.rotate(newUserKey, masterPasswordHash); - } - - private async encryptPrivateKey(newUserKey: UserKey): Promise { - const privateKey = await this.cryptoService.getPrivateKey(); - if (!privateKey) { - return; - } - return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString; - } - - private async encryptFolders(newUserKey: UserKey): Promise { - const folders = await firstValueFrom(this.folderService.folderViews$); - if (!folders) { - return; - } - return await Promise.all( - folders.map(async (folder) => { - const encryptedFolder = await this.folderService.encrypt(folder, newUserKey); - return new FolderWithIdRequest(encryptedFolder); - }), - ); - } - - private async encryptCiphers(newUserKey: UserKey): Promise { - const ciphers = await this.cipherService.getAllDecrypted(); - if (!ciphers) { - return; - } - return await Promise.all( - ciphers.map(async (cipher) => { - const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey); - return new CipherWithIdRequest(encryptedCipher); - }), - ); - } - - private async encryptSends(newUserKey: UserKey): Promise { - const sends = await firstValueFrom(this.sendService.sends$); - if (!sends) { - return; - } - return await Promise.all( - sends.map(async (send) => { - const sendKey = await this.encryptService.decryptToBytes(send.key, null); - send.key = (await this.encryptService.encrypt(sendKey, newUserKey)) ?? send.key; - return new SendWithIdRequest(send); - }), - ); - } -} diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 9abd4436088..8ef5725007a 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -1,40 +1,28 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, Observable } from "rxjs"; +import { Observable } from "rxjs"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; -import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { UpdateKeyRequest } from "@bitwarden/common/models/request/update-key.request"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; -import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; import { DialogService } from "@bitwarden/components"; -import { EmergencyAccessService } from "../emergency-access"; +import { UserKeyRotationService } from "../key-rotation/user-key-rotation.service"; @Component({ selector: "app-change-password", @@ -58,20 +46,14 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { platformUtilsService: PlatformUtilsService, policyService: PolicyService, private auditService: AuditService, - private folderService: FolderService, private cipherService: CipherService, private syncService: SyncService, - private emergencyAccessService: EmergencyAccessService, private apiService: ApiService, - private sendService: SendService, - private organizationService: OrganizationService, private router: Router, - private organizationApiService: OrganizationApiServiceAbstraction, - private organizationUserService: OrganizationUserService, dialogService: DialogService, private userVerificationService: UserVerificationService, - private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, private configService: ConfigServiceAbstraction, + private keyRotationService: UserKeyRotationService, ) { super( i18nService, @@ -201,7 +183,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { try { if (this.rotateUserKey) { this.formPromise = this.apiService.postPassword(request).then(() => { - return this.updateKey(newMasterKey, request.newMasterPasswordHash); + return this.updateKey(); }); } else { this.formPromise = this.apiService.postPassword(request); @@ -220,81 +202,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { } } - private async updateKey(masterKey: MasterKey, masterPasswordHash: string) { - const [newUserKey, masterKeyEncUserKey] = await this.cryptoService.makeUserKey(masterKey); - const userPrivateKey = await this.cryptoService.getPrivateKey(); - let encPrivateKey: EncString = null; - if (userPrivateKey != null) { - encPrivateKey = await this.cryptoService.encrypt(userPrivateKey, newUserKey); - } - const request = new UpdateKeyRequest(); - request.privateKey = encPrivateKey != null ? encPrivateKey.encryptedString : null; - request.key = masterKeyEncUserKey.encryptedString; - request.masterPasswordHash = masterPasswordHash; - - const folders = await firstValueFrom(this.folderService.folderViews$); - for (let i = 0; i < folders.length; i++) { - if (folders[i].id == null) { - continue; - } - const folder = await this.folderService.encrypt(folders[i], newUserKey); - request.folders.push(new FolderWithIdRequest(folder)); - } - - const ciphers = await this.cipherService.getAllDecrypted(); - for (let i = 0; i < ciphers.length; i++) { - if (ciphers[i].organizationId != null) { - continue; - } - - const cipher = await this.cipherService.encrypt(ciphers[i], newUserKey); - request.ciphers.push(new CipherWithIdRequest(cipher)); - } - - const sends = await firstValueFrom(this.sendService.sends$); - await Promise.all( - sends.map(async (send) => { - const sendKey = await this.cryptoService.decryptToBytes(send.key, null); - send.key = (await this.cryptoService.encrypt(sendKey, newUserKey)) ?? send.key; - request.sends.push(new SendWithIdRequest(send)); - }), - ); - - await this.deviceTrustCryptoService.rotateDevicesTrust(newUserKey, masterPasswordHash); - - await this.apiService.postAccountKey(request); - - await this.emergencyAccessService.rotate(newUserKey); - - await this.updateAllResetPasswordKeys(newUserKey, masterPasswordHash); - } - - private async updateAllResetPasswordKeys(userKey: UserKey, masterPasswordHash: string) { - const orgs = await this.organizationService.getAll(); - - for (const org of orgs) { - // If not already enrolled, skip - if (!org.resetPasswordEnrolled) { - continue; - } - - // Retrieve public key - const response = await this.organizationApiService.getKeys(org.id); - const publicKey = Utils.fromB64ToArray(response?.publicKey); - - // Re-enroll - encrypt user's encKey.key with organization public key - const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey); - - // Create/Execute request - const request = new OrganizationUserResetPasswordEnrollmentRequest(); - request.masterPasswordHash = masterPasswordHash; - request.resetPasswordKey = encryptedKey.encryptedString; - - await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( - org.id, - org.userId, - request, - ); - } + private async updateKey() { + await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword); } } diff --git a/apps/web/src/app/auth/settings/settings.module.ts b/apps/web/src/app/auth/settings/settings.module.ts index 5e723b3d413..9d343cf9485 100644 --- a/apps/web/src/app/auth/settings/settings.module.ts +++ b/apps/web/src/app/auth/settings/settings.module.ts @@ -4,6 +4,7 @@ import { PasswordCalloutComponent } from "@bitwarden/auth"; import { SharedModule } from "../../shared"; import { EmergencyAccessModule } from "../emergency-access"; +import { UserKeyRotationModule } from "../key-rotation/user-key-rotation.module"; import { ChangePasswordComponent } from "./change-password.component"; import { WebauthnLoginSettingsModule } from "./webauthn-login-settings"; @@ -14,6 +15,7 @@ import { WebauthnLoginSettingsModule } from "./webauthn-login-settings"; WebauthnLoginSettingsModule, EmergencyAccessModule, PasswordCalloutComponent, + UserKeyRotationModule, ], declarations: [ChangePasswordComponent], providers: [], diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 8f910af3ca5..42cab8c21b1 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -99,7 +99,6 @@ import { RegisterRequest } from "../models/request/register.request"; import { StorageRequest } from "../models/request/storage.request"; import { UpdateAvatarRequest } from "../models/request/update-avatar.request"; import { UpdateDomainsRequest } from "../models/request/update-domains.request"; -import { UpdateKeyRequest } from "../models/request/update-key.request"; import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request"; import { VerifyEmailRequest } from "../models/request/verify-email.request"; import { BreachAccountResponse } from "../models/response/breach-account.response"; @@ -176,7 +175,6 @@ export abstract class ApiService { postAccountStorage: (request: StorageRequest) => Promise; postAccountPayment: (request: PaymentRequest) => Promise; postAccountLicense: (data: FormData) => Promise; - postAccountKey: (request: UpdateKeyRequest) => Promise; postAccountKeys: (request: KeysRequest) => Promise; postAccountVerifyEmail: () => Promise; postAccountVerifyEmailToken: (request: VerifyEmailRequest) => Promise; diff --git a/libs/common/src/admin-console/abstractions/organization-user/requests/organization-user-reset-password-enrollment.request.ts b/libs/common/src/admin-console/abstractions/organization-user/requests/organization-user-reset-password-enrollment.request.ts index 1078c59c4ce..ab655466f82 100644 --- a/libs/common/src/admin-console/abstractions/organization-user/requests/organization-user-reset-password-enrollment.request.ts +++ b/libs/common/src/admin-console/abstractions/organization-user/requests/organization-user-reset-password-enrollment.request.ts @@ -3,3 +3,7 @@ import { SecretVerificationRequest } from "../../../../auth/models/request/secre export class OrganizationUserResetPasswordEnrollmentRequest extends SecretVerificationRequest { resetPasswordKey: string; } + +export class OrganizationUserResetPasswordWithIdRequest extends OrganizationUserResetPasswordEnrollmentRequest { + organizationId: string; +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 95dbf5d2db0..0f3a3e9fcd8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -10,6 +10,7 @@ export enum FeatureFlag { FlexibleCollections = "flexible-collections", FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional BulkCollectionAccess = "bulk-collection-access", + KeyRotationImprovements = "key-rotation-improvements", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/models/request/update-key.request.ts b/libs/common/src/models/request/update-key.request.ts deleted file mode 100644 index 11d2dc46ff1..00000000000 --- a/libs/common/src/models/request/update-key.request.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SendWithIdRequest } from "../../tools/send/models/request/send-with-id.request"; -import { CipherWithIdRequest } from "../../vault/models/request/cipher-with-id.request"; -import { FolderWithIdRequest } from "../../vault/models/request/folder-with-id.request"; - -export class UpdateKeyRequest { - ciphers: CipherWithIdRequest[] = []; - folders: FolderWithIdRequest[] = []; - sends: SendWithIdRequest[] = []; - masterPasswordHash: string; - privateKey: string; - key: string; -} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 49d3db52dc2..c969bc7e841 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -105,7 +105,6 @@ import { RegisterRequest } from "../models/request/register.request"; import { StorageRequest } from "../models/request/storage.request"; import { UpdateAvatarRequest } from "../models/request/update-avatar.request"; import { UpdateDomainsRequest } from "../models/request/update-domains.request"; -import { UpdateKeyRequest } from "../models/request/update-key.request"; import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request"; import { VerifyEmailRequest } from "../models/request/verify-email.request"; import { BreachAccountResponse } from "../models/response/breach-account.response"; @@ -412,10 +411,6 @@ export class ApiService implements ApiServiceAbstraction { return this.send("POST", "/accounts/keys", request, true, false); } - postAccountKey(request: UpdateKeyRequest): Promise { - return this.send("POST", "/accounts/key", request, true, false); - } - postAccountVerifyEmail(): Promise { return this.send("POST", "/accounts/verify-email", null, true, false); } diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 544f624a054..0e4187d2e3f 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -1,9 +1,10 @@ import { Observable } from "rxjs"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; +import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; export abstract class SendService { @@ -17,6 +18,13 @@ export abstract class SendService { key?: SymmetricCryptoKey, ) => Promise<[Send, EncArrayBuffer]>; get: (id: string) => Send; + /** + * Provides re-encrypted user sends for the key rotation process + * @param newUserKey The new user key to use for re-encryption + * @throws Error if the new user key is null or undefined + * @returns A list of user sends that have been re-encrypted with the new user key + */ + getRotatedKeys: (newUserKey: UserKey) => Promise; /** * @deprecated Do not call this, use the sends$ observable collection */ diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 69971dc5487..31992fb8749 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -7,6 +7,7 @@ import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../platform/services/container.service"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; @@ -92,6 +93,37 @@ describe("SendService", () => { expect(stateService.getDecryptedSends).toHaveBeenCalledTimes(1); }); + describe("getRotatedKeys", () => { + let encryptedKey: EncString; + beforeEach(() => { + cryptoService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); + encryptedKey = new EncString("Re-encrypted Send Key"); + cryptoService.encrypt.mockResolvedValue(encryptedKey); + }); + + it("returns re-encrypted user sends", async () => { + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const result = await sendService.getRotatedKeys(newUserKey); + + expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]); + }); + + it("returns null if there are no sends", async () => { + sendService.replace(null); + + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const result = await sendService.getRotatedKeys(newUserKey); + + expect(result).toEqual([]); + }); + + it("throws if the new user key is null", async () => { + await expect(sendService.getRotatedKeys(null)).rejects.toThrowError( + "New user key is required for rotation.", + ); + }); + }); + // InternalSendService it("upsert", async () => { diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index e09ae3aa86f..26c0b1335d4 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,12 +7,13 @@ import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../../platform/models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendFile } from "../models/domain/send-file"; import { SendText } from "../models/domain/send-text"; +import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; @@ -212,6 +213,22 @@ export class SendService implements InternalSendServiceAbstraction { await this.stateService.setEncryptedSends(sends); } + async getRotatedKeys(newUserKey: UserKey): Promise { + if (newUserKey == null) { + throw new Error("New user key is required for rotation."); + } + + const requests = await Promise.all( + this._sends.value.map(async (send) => { + const sendKey = await this.cryptoService.decryptToBytes(send.key); + send.key = await this.cryptoService.encrypt(sendKey, newUserKey); + return new SendWithIdRequest(send); + }), + ); + // separate return for easier debugging + return requests; + } + private parseFile(send: Send, file: File, key: SymmetricCryptoKey): Promise { return new Promise((resolve, reject) => { const reader = new FileReader();