diff --git a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts b/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts index 04b24e0eb0..72f9744056 100644 --- a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts +++ b/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts @@ -29,4 +29,42 @@ export class RotateableKeySetService { const encryptedPublicKey = await this.encryptService.encrypt(rawPublicKey, userKey); return new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey); } + + /** + * Rotates the current user's `UserKey` and updates the provided `RotateableKeySet` with the new keys. + * + * @param keySet The current `RotateableKeySet` for the user + * @returns The updated `RotateableKeySet` with the new `UserKey` + */ + async rotateKeySet( + keySet: RotateableKeySet, + oldUserKey: SymmetricCryptoKey, + newUserKey: SymmetricCryptoKey, + ): Promise> { + // validate parameters + if (!keySet) { + throw new Error("failed to rotate key set: keySet is required"); + } + if (!oldUserKey) { + throw new Error("failed to rotate key set: oldUserKey is required"); + } + if (!newUserKey) { + throw new Error("failed to rotate key set: newUserKey is required"); + } + + const publicKey = await this.encryptService.decryptToBytes( + keySet.encryptedPublicKey, + oldUserKey, + ); + const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey); + const newEncryptedUserKey = await this.encryptService.rsaEncrypt(newUserKey.key, publicKey); + + const newRotateableKeySet = new RotateableKeySet( + newEncryptedUserKey, + newEncryptedPublicKey, + keySet.encryptedPrivateKey, + ); + + return newRotateableKeySet; + } } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts index 2d8876915a..f96ed38bdc 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts @@ -1,4 +1,6 @@ +import { RotateableKeySet } from "@bitwarden/auth/common"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum"; @@ -9,11 +11,33 @@ export class WebauthnLoginCredentialResponse extends BaseResponse { id: string; name: string; prfStatus: WebauthnLoginCredentialPrfStatus; + encryptedPublicKey?: string; + encryptedUserKey?: string; constructor(response: unknown) { super(response); this.id = this.getResponseProperty("Id"); this.name = this.getResponseProperty("Name"); this.prfStatus = this.getResponseProperty("PrfStatus"); + this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey"); + this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey"); + } + + getRotateableKeyset(): RotateableKeySet { + if (!EncString.isSerializedEncString(this.encryptedUserKey)) { + throw new Error("Invalid encrypted user key"); + } + if (!EncString.isSerializedEncString(this.encryptedPublicKey)) { + throw new Error("Invalid encrypted public key"); + } + + return new RotateableKeySet( + new EncString(this.encryptedUserKey), + new EncString(this.encryptedPublicKey), + ); + } + + hasPrfKeyset(): boolean { + return this.encryptedUserKey != null && this.encryptedPublicKey != null; } } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts index efa32d0c6f..333140a171 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts @@ -45,8 +45,12 @@ export class WebAuthnLoginAdminApiService { return true; } - getCredentials(): Promise> { - return this.apiService.send("GET", "/webauthn", null, true, true); + async getCredentials(): Promise> { + const response = await this.apiService.send("GET", "/webauthn", null, true, true); + return new ListResponse( + response, + WebauthnLoginCredentialResponse, + ); } async deleteCredential(credentialId: string, request: SecretVerificationRequest): Promise { diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index 531dd3a813..4ad712b935 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -9,7 +9,8 @@ import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/mod import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { PrfKey } from "@bitwarden/common/types/key"; +import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; @@ -184,6 +185,67 @@ describe("WebauthnAdminService", () => { } }); }); + + describe("rotateCredentials", () => { + it("should throw when old userkey is null", async () => { + const newUserKey = makeSymmetricCryptoKey(64) as UserKey; + try { + await service.rotateWebAuthnKeys(null, newUserKey); + } catch (error) { + expect(error).toEqual(new Error("oldUserKey is required")); + } + }); + it("should throw when new userkey is null", async () => { + const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; + try { + await service.rotateWebAuthnKeys(oldUserKey, null); + } catch (error) { + expect(error).toEqual(new Error("newUserKey is required")); + } + }); + it("should call rotateKeySet with the correct parameters", async () => { + const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; + const newUserKey = makeSymmetricCryptoKey(64) as UserKey; + const mockEncryptedPublicKey = new EncString("test_encryptedPublicKey"); + const mockEncryptedUserKey = new EncString("test_encryptedUserKey"); + jest.spyOn(apiService, "getCredentials").mockResolvedValue({ + data: [ + { + getRotateableKeyset: () => + new RotateableKeySet(mockEncryptedUserKey, mockEncryptedPublicKey), + hasPrfKeyset: () => true, + }, + ], + } as any); + const rotateKeySetMock = jest + .spyOn(rotateableKeySetService, "rotateKeySet") + .mockResolvedValue( + new RotateableKeySet(mockEncryptedUserKey, mockEncryptedPublicKey), + ); + await service.rotateWebAuthnKeys(oldUserKey, newUserKey); + expect(rotateKeySetMock).toHaveBeenCalledWith( + expect.any(RotateableKeySet), + oldUserKey, + newUserKey, + ); + }); + it("should skip rotation when no prf keyset is available", async () => { + const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; + const newUserKey = makeSymmetricCryptoKey(64) as UserKey; + jest.spyOn(apiService, "getCredentials").mockResolvedValue({ + data: [ + { + getRotateableKeyset: () => + new RotateableKeySet(new EncString("test_encryptedUserKey"), null), + hasPrfKeyset: () => false, + }, + ], + } as any); + const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet"); + await service.rotateWebAuthnKeys(oldUserKey, newUserKey); + expect(rotateKeySetMock).not.toHaveBeenCalled(); + }); + }); }); function createCredentialCreateOptions(): CredentialCreateOptionsView { diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index 42b6981c21..fadf5cbd29 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -4,10 +4,12 @@ import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, import { PrfKeySet } from "@bitwarden/auth/common"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; +import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserKey } from "@bitwarden/common/types/key"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; @@ -273,4 +275,44 @@ export class WebauthnLoginAdminService { private refresh() { this._refresh$.next(); } + + /** + * Creates rotate credential requests for the purpose of user key rotation. + * This works by fetching the current webauthn credentials, filtering out the ones that have a PRF keyset, + * and rotating these using the rotateable key set service. + * + * @param oldUserKey The old user key + * @param newUserKey The new user key + * @returns A promise that returns an array of rotate credential requests when resolved. + */ + async rotateWebAuthnKeys( + oldUserKey: UserKey, + newUserKey: UserKey, + ): Promise { + if (!oldUserKey) { + throw new Error("oldUserKey is required"); + } + if (!newUserKey) { + throw new Error("newUserKey is required"); + } + + return Promise.all( + (await this.apiService.getCredentials()).data + .filter((credential) => credential.hasPrfKeyset()) + .map(async (response) => { + const keyset = response.getRotateableKeyset(); + const rotatedKeyset = await this.rotateableKeySetService.rotateKeySet( + keyset, + oldUserKey, + newUserKey, + ); + const request = new WebauthnRotateCredentialRequest( + response.id, + rotatedKeyset.encryptedPublicKey, + rotatedKeyset.encryptedUserKey, + ); + return 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 index f8637110e7..9ea40c88e6 100644 --- 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 @@ -1,4 +1,5 @@ import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; +import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; 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"; @@ -14,4 +15,5 @@ export class UpdateKeyRequest { sends: SendWithIdRequest[] = []; emergencyAccessKeys: EmergencyAccessWithIdRequest[] = []; resetPasswordKeys: OrganizationUserResetPasswordWithIdRequest[] = []; + webauthnKeys: WebauthnRotateCredentialRequest[] = []; } 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 index c3d568e118..5addcceabf 100644 --- 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 @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; @@ -30,6 +30,7 @@ import { } from "../../../../../../libs/common/spec/fake-account-service"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { StateService } from "../../core"; +import { WebauthnLoginAdminService } from "../core"; import { EmergencyAccessService } from "../emergency-access"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; @@ -51,6 +52,7 @@ describe("KeyRotationService", () => { let mockConfigService: MockProxy; let mockKdfConfigService: MockProxy; let mockSyncService: MockProxy; + let mockWebauthnLoginAdminService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); @@ -71,6 +73,7 @@ describe("KeyRotationService", () => { mockConfigService = mock(); mockKdfConfigService = mock(); mockSyncService = mock(); + mockWebauthnLoginAdminService = mock(); keyRotationService = new UserKeyRotationService( mockMasterPasswordService, @@ -87,6 +90,7 @@ describe("KeyRotationService", () => { mockAccountService, mockKdfConfigService, mockSyncService, + mockWebauthnLoginAdminService, ); }); @@ -115,6 +119,9 @@ describe("KeyRotationService", () => { // Mock private key mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any); + mockCryptoService.userKey$.mockReturnValue( + of(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); // Mock ciphers const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")]; @@ -130,6 +137,8 @@ describe("KeyRotationService", () => { sends = new BehaviorSubject(mockSends); mockSendService.sends$ = sends; + mockWebauthnLoginAdminService.rotateWebAuthnKeys.mockResolvedValue([]); + // Mock encryption methods mockEncryptService.encrypt.mockResolvedValue({ encryptedString: "mockEncryptedData", 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 index cac2dafd51..883d0fef88 100644 --- 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 @@ -18,6 +18,7 @@ import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/ciph 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 { WebauthnLoginAdminService } from "../core"; import { EmergencyAccessService } from "../emergency-access"; import { UpdateKeyRequest } from "./request/update-key.request"; @@ -40,6 +41,7 @@ export class UserKeyRotationService { private accountService: AccountService, private kdfConfigService: KdfConfigService, private syncService: SyncService, + private webauthnLoginAdminService: WebauthnLoginAdminService, ) {} /** @@ -70,6 +72,7 @@ export class UserKeyRotationService { // Set master key again in case it was lost (could be lost on refresh) const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const oldUserKey = await firstValueFrom(this.cryptoService.userKey$(userId)); await this.masterPasswordService.setMasterKey(masterKey, userId); const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey); @@ -94,6 +97,10 @@ export class UserKeyRotationService { request.sends = await this.sendService.getRotatedKeys(newUserKey); request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey); request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey); + request.webauthnKeys = await this.webauthnLoginAdminService.rotateWebAuthnKeys( + oldUserKey, + newUserKey, + ); await this.apiService.postUserKeyUpdate(request); diff --git a/libs/auth/src/common/models/domain/rotateable-key-set.ts b/libs/auth/src/common/models/domain/rotateable-key-set.ts index 9930010c15..5e0faea339 100644 --- a/libs/auth/src/common/models/domain/rotateable-key-set.ts +++ b/libs/auth/src/common/models/domain/rotateable-key-set.ts @@ -27,7 +27,7 @@ export class RotateableKeySet