From cc8bd71775c8131e4529e90e78c61b25c77ecba5 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 10 Oct 2025 23:04:47 +0200 Subject: [PATCH] [PM-21033/PM-22863] User Encryption v2 (#14942) * Add new encrypt service functions * Undo changes * Cleanup * Fix build * Fix comments * Switch encrypt service to use SDK functions * Move remaining functions to PureCrypto * Tests * Increase test coverage * Split up userkey rotation v2 and add tests * Fix eslint * Fix type errors * Fix tests * Implement signing keys * Fix sdk init * Remove key rotation v2 flag * Fix parsing when user does not have signing keys * Clear up trusted key naming * Split up getNewAccountKeys * Add trim and lowercase * Replace user.email with masterKeySalt * Add wasTrustDenied to verifyTrust in key rotation service * Move testable userkey rotation service code to testable class * Fix build * Add comments * Undo changes * Fix incorrect behavior on aborting key rotation and fix import * Fix tests * Make members of userkey rotation service protected * Fix type error * Cleanup and add injectable annotation * Fix tests * Update apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Remove v1 rotation request * Add upgrade to user encryption v2 * Fix types * Update sdk method calls * Update request models for new server api for rotation * Fix build * Update userkey rotation for new server API * Update crypto client call for new sdk changes * Fix rotation with signing keys * Cargo lock * Fix userkey rotation service * Fix types * Undo changes to feature flag service * Fix linting * [PM-22863] Account security state (#15309) * Add account security state * Update key rotation * Rename * Fix build * Cleanup * Further cleanup * Tests * Increase test coverage * Add test * Increase test coverage * Fix builds and update sdk * Fix build * Fix tests * Reset changes to encrypt service * Cleanup * Add comment * Cleanup * Cleanup * Rename model * Cleanup * Fix build * Clean up * Fix types * Cleanup * Cleanup * Cleanup * Add test * Simplify request model * Rename and add comments * Fix tests * Update responses to use less strict typing * Fix response parsing for v1 users * Update libs/common/src/key-management/keys/response/private-keys.response.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update libs/common/src/key-management/keys/response/private-keys.response.ts Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Fix build * Fix build * Fix build * Undo change * Fix attachments not encrypting for v2 users --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> --- .../browser/src/background/main.background.ts | 8 +- .../service-container/service-container.ts | 7 + .../settings/account/profile.component.ts | 4 +- ...c-key-encryption-key-pair-request.model.ts | 19 + ...ignature-key-pair-request-request.model.ts | 18 + .../request/account-keys.request.ts | 74 +- .../types/v1-cryptographic-state.ts | 10 + .../types/v2-cryptographic-state.ts | 49 ++ .../user-key-rotation.service.spec.ts | 649 ++++++++++++++---- .../key-rotation/user-key-rotation.service.ts | 449 ++++++++---- .../src/services/jslib-services.module.ts | 16 + .../services/web-crypto-function.service.ts | 3 +- .../enums/signing-key-type.enum.ts | 13 + .../keys/response/private-keys.response.ts | 55 ++ ...public-key-encryption-key-pair.response.ts | 32 + .../keys/response/public-keys.response.ts | 44 ++ .../response/signature-key-pair.response.ts | 22 + .../key-api-service.abstraction.ts | 5 + .../default-key-api-service.service.ts | 15 + .../abstractions/security-state.service.ts | 21 + .../request/security-state.request.ts | 8 + .../response/security-state.response.ts | 16 + .../services/security-state.service.ts | 26 + .../state/security-state.state.ts | 12 + libs/common/src/key-management/types.ts | 30 + .../src/models/response/profile.response.ts | 11 + .../services/key-state/user-key.state.ts | 10 + .../services/sdk/default-sdk.service.spec.ts | 8 +- .../services/sdk/default-sdk.service.ts | 89 ++- .../sync/default-sync.service.spec.ts | 141 ++++ .../src/platform/sync/default-sync.service.ts | 25 +- libs/common/src/types/key.ts | 3 +- .../src/abstractions/key.service.ts | 23 +- libs/key-management/src/key.service.spec.ts | 52 +- libs/key-management/src/key.service.ts | 42 +- .../services/node-crypto-function.service.ts | 11 +- 36 files changed, 1693 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts create mode 100644 apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts create mode 100644 apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts create mode 100644 apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts create mode 100644 libs/common/src/key-management/enums/signing-key-type.enum.ts create mode 100644 libs/common/src/key-management/keys/response/private-keys.response.ts create mode 100644 libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts create mode 100644 libs/common/src/key-management/keys/response/public-keys.response.ts create mode 100644 libs/common/src/key-management/keys/response/signature-key-pair.response.ts create mode 100644 libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts create mode 100644 libs/common/src/key-management/keys/services/default-key-api-service.service.ts create mode 100644 libs/common/src/key-management/security-state/abstractions/security-state.service.ts create mode 100644 libs/common/src/key-management/security-state/request/security-state.request.ts create mode 100644 libs/common/src/key-management/security-state/response/security-state.response.ts create mode 100644 libs/common/src/key-management/security-state/services/security-state.service.ts create mode 100644 libs/common/src/key-management/security-state/state/security-state.state.ts create mode 100644 libs/common/src/key-management/types.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bd28ddfbbb..21609432a4 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { DefaultVaultTimeoutSettingsService, @@ -452,6 +454,7 @@ export default class MainBackground { taskService: TaskService; cipherEncryptionService: CipherEncryptionService; private restrictedItemTypesService: RestrictedItemTypesService; + private securityStateService: SecurityStateService; ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; @@ -668,6 +671,8 @@ export default class MainBackground { logoutCallback, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, this.globalStateProvider, @@ -830,6 +835,7 @@ export default class MainBackground { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, this.apiService, this.stateProvider, this.configService, @@ -999,7 +1005,6 @@ export default class MainBackground { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.providerService = new ProviderService(this.stateProvider); - this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, @@ -1025,6 +1030,7 @@ export default class MainBackground { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.syncServiceListener = new SyncServiceListener( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c2fffef668..d13d251bce 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -73,6 +73,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -305,6 +307,7 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + securityStateService: SecurityStateService; cipherArchiveService: CipherArchiveService; constructor() { @@ -406,6 +409,8 @@ export class ServiceContainer { this.derivedStateProvider, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.environmentService = new DefaultEnvironmentService( this.stateProvider, this.accountService, @@ -612,6 +617,7 @@ export class ServiceContainer { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, this.apiService, this.stateProvider, this.configService, @@ -818,6 +824,7 @@ export class ServiceContainer { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.totpService = new TotpService(this.sdkService); diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 54f9ac5829..1f4fa57849 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -56,7 +56,9 @@ export class ProfileComponent implements OnInit, OnDestroy { this.profile = await this.apiService.getProfile(); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.fingerprintMaterial = userId; - const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId)); + const publicKey = (await firstValueFrom( + this.keyService.userPublicKey$(userId), + )) as UserPublicKey; if (publicKey == null) { this.logService.error( "[ProfileComponent] No public key available for the user: " + diff --git a/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts new file mode 100644 index 0000000000..7504b599e1 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts @@ -0,0 +1,19 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +export class PublicKeyEncryptionKeyPairRequestModel { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: string; + signedPublicKey: SignedPublicKey | null; + + constructor( + wrappedPrivateKey: WrappedPrivateKey, + publicKey: UnsignedPublicKey, + signedPublicKey: SignedPublicKey | null, + ) { + this.wrappedPrivateKey = wrappedPrivateKey; + this.publicKey = Utils.fromBufferToB64(publicKey); + this.signedPublicKey = signedPublicKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts new file mode 100644 index 0000000000..2dbf75e2ff --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts @@ -0,0 +1,18 @@ +import { VerifyingKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; +import { SignatureAlgorithm } from "@bitwarden/sdk-internal"; + +export class SignatureKeyPairRequestModel { + signatureAlgorithm: SignatureAlgorithm; + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + + constructor( + signingKey: WrappedSigningKey, + verifyingKey: VerifyingKey, + signingKeyAlgorithm: SignatureAlgorithm, + ) { + this.signatureAlgorithm = signingKeyAlgorithm; + this.wrappedSigningKey = signingKey; + this.verifyingKey = verifyingKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts index 1c9b6c9cec..2c8964a358 100644 --- a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts @@ -1,10 +1,70 @@ -export class AccountKeysRequest { - // Other keys encrypted by the userkey - userKeyEncryptedAccountPrivateKey: string; - accountPublicKey: string; +import { SecurityStateRequest } from "@bitwarden/common/key-management/security-state/request/security-state.request"; +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PureCrypto } from "@bitwarden/sdk-internal"; - constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) { - this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey; - this.accountPublicKey = accountPublicKey; +import { PublicKeyEncryptionKeyPairRequestModel } from "../model/public-key-encryption-key-pair-request.model"; +import { SignatureKeyPairRequestModel } from "../model/signature-key-pair-request-request.model"; +import { V1UserCryptographicState } from "../types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "../types/v2-cryptographic-state"; + +// This request contains other account-owned keys that are encrypted with the user key. +export class AccountKeysRequest { + /** + * @deprecated + */ + userKeyEncryptedAccountPrivateKey: WrappedPrivateKey | null = null; + /** + * @deprecated + */ + accountPublicKey: string | null = null; + + publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairRequestModel | null = null; + signatureKeyPair: SignatureKeyPairRequestModel | null = null; + securityState: SecurityStateRequest | null = null; + + constructor() {} + + static fromV1CryptographicState(state: V1UserCryptographicState): AccountKeysRequest { + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + null, + ); + + return request; + } + + static async fromV2CryptographicState( + state: V2UserCryptographicState, + ): Promise { + // Ensure the SDK is loaded, since it is used to derive the signature algorithm. + await SdkLoadService.Ready; + + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey!; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + state.publicKeyEncryptionKeyPair.signedPublicKey, + ); + request.signatureKeyPair = new SignatureKeyPairRequestModel( + state.signatureKeyPair.wrappedSigningKey, + state.signatureKeyPair.verifyingKey, + PureCrypto.key_algorithm_for_verifying_key( + Utils.fromB64ToArray(state.signatureKeyPair.verifyingKey), + ), + ); + request.securityState = new SecurityStateRequest( + state.securityState.securityState, + state.securityState.securityStateVersion, + ); + + return request; } } diff --git a/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts new file mode 100644 index 0000000000..220bdd3775 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts @@ -0,0 +1,10 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; + +export type V1UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; diff --git a/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts new file mode 100644 index 0000000000..da52d6d6ee --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts @@ -0,0 +1,49 @@ +import { + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { SignedPublicKey, UserCryptoV2KeysResponse } from "@bitwarden/sdk-internal"; + +export type V2UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + signedPublicKey: SignedPublicKey; + }; + signatureKeyPair: { + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + }; + securityState: { + securityState: SignedSecurityState; + securityStateVersion: number; + }; +}; + +export function fromSdkV2KeysToV2UserCryptographicState( + response: UserCryptoV2KeysResponse, +): V2UserCryptographicState { + return { + userKey: SymmetricCryptoKey.fromString(response.userKey) as UserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: response.privateKey as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey, + signedPublicKey: response.signedPublicKey, + }, + signatureKeyPair: { + wrappedSigningKey: response.signingKey as WrappedSigningKey, + verifyingKey: response.verifyingKey as VerifyingKey, + }, + securityState: { + securityState: response.securityState as SignedSecurityState, + securityStateVersion: response.securityVersion, + }, + }; +} diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index 0ffa48048f..b790fb8409 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs"; import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -11,10 +12,22 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedPublicKey, + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; @@ -33,13 +46,14 @@ import { PBKDF2KdfConfig, KdfConfigService, KdfConfig, + KdfType, } from "@bitwarden/key-management"; import { AccountRecoveryTrustComponent, EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth"; @@ -48,11 +62,18 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type"; import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request"; +import { AccountKeysRequest } from "./request/account-keys.request"; import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; -import { UserKeyRotationService } from "./user-key-rotation.service"; +import { + UserKeyRotationService, + V1CryptographicStateParameters, + V2CryptographicStateParameters, +} from "./user-key-rotation.service"; const initialPromptedOpenTrue = jest.fn(); initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) }); @@ -120,6 +141,21 @@ function createMockWebauthn(id: string): any { } as WebauthnRotateCredentialRequest; } +const TEST_VECTOR_USER_KEY_V1 = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V1 = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; +const TEST_VECTOR_PUBLIC_KEY_V1 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_PRIVATE_KEY_V1_ROTATED = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|AAAAff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; + +const TEST_VECTOR_USER_KEY_V2 = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedPrivateKey; +const TEST_VECTOR_SIGNING_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedSigningKey; +const TEST_VECTOR_VERIFYING_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as VerifyingKey; +const TEST_VECTOR_SECURITY_STATE_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedSecurityState; +const TEST_VECTOR_PUBLIC_KEY_V2 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedPublicKey; + class TestUserKeyRotationService extends UserKeyRotationService { override rotateUserKeyMasterPasswordAndEncryptedData( currentMasterPassword: string, @@ -138,22 +174,17 @@ class TestUserKeyRotationService extends UserKeyRotationService { return super.ensureIsAllowedToRotateUserKey(); } override getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey); + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV1(cryptographicStateParameters); } override getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey); + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV2(userId, kdfConfig, email, cryptographicStateParameters); } override createMasterPasswordUnlockDataRequest( userKey: UserKey, @@ -176,8 +207,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeyKdfConfig: KdfConfig; masterPasswordHint: string; }, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { return super.getAccountUnlockDataRequest( userId, @@ -190,8 +221,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { } override verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { return super.verifyTrust(user); } @@ -202,14 +233,6 @@ class TestUserKeyRotationService extends UserKeyRotationService { ): Promise { return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user); } - override makeNewUserKeyV1(oldUserKey: UserKey): Promise { - return super.makeNewUserKeyV1(oldUserKey); - } - override makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - return super.makeNewUserKeyV2(oldUserKey); - } override isV1User(userKey: UserKey): boolean { return super.isV1User(userKey); } @@ -227,6 +250,13 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeySalt, ); } + override getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + return super.getCryptographicStateForUser(user); + } } describe("KeyRotationService", () => { @@ -251,6 +281,8 @@ describe("KeyRotationService", () => { let mockI18nService: MockProxy; let mockCryptoFunctionService: MockProxy; let mockKdfConfigService: MockProxy; + let mockSdkClientFactory: MockProxy; + let mockSecurityStateService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -261,6 +293,9 @@ describe("KeyRotationService", () => { const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; + const mockMakeKeysForUserCryptoV2 = jest.fn(); + const mockGetV2RotatedAccountKeys = jest.fn(); + beforeAll(() => { mockApiService = mock(); mockCipherService = mock(); @@ -271,7 +306,7 @@ describe("KeyRotationService", () => { mockTrustedPublicKeys.map((key) => { return { publicKey: key, - id: "mockId", + id: "00000000-0000-0000-0000-000000000000" as UserId, granteeId: "mockGranteeId", name: "mockName", email: "mockEmail", @@ -306,6 +341,17 @@ describe("KeyRotationService", () => { mockDialogService = mock(); mockCryptoFunctionService = mock(); mockKdfConfigService = mock(); + mockSdkClientFactory = mock(); + mockSdkClientFactory.createSdkClient.mockResolvedValue({ + crypto: () => { + return { + initialize_user_crypto: jest.fn(), + make_keys_for_user_crypto_v2: mockMakeKeysForUserCryptoV2, + get_v2_rotated_account_keys: mockGetV2RotatedAccountKeys, + } as any; + }, + } as BitwardenClient); + mockSecurityStateService = mock(); keyRotationService = new TestUserKeyRotationService( mockApiService, @@ -327,6 +373,8 @@ describe("KeyRotationService", () => { mockConfigService, mockCryptoFunctionService, mockKdfConfigService, + mockSdkClientFactory, + mockSecurityStateService, ); }); @@ -334,13 +382,16 @@ describe("KeyRotationService", () => { jest.clearAllMocks(); jest.mock("@bitwarden/key-management-ui"); jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64)); - jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70)); jest .spyOn(PureCrypto, "encrypt_user_key_with_master_password") .mockReturnValue("mockNewUserKey"); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); }); - describe("rotateUserKeyAndEncryptedData", () => { + describe("rotateUserKeyMasterPasswordAndEncryptedData", () => { let privateKey: BehaviorSubject; let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; @@ -438,6 +489,64 @@ describe("KeyRotationService", () => { expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2); }); + it("passes the EnrollAeadOnKeyRotation feature flag to getRotatedAccountKeysFlagged", async () => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: { + userKeyEncryptedAccountPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + accountPublicKey: TEST_VECTOR_PUBLIC_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + signatureAlgorithm: "ed25519", + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }, + }, + }); + + await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + "masterPasswordHint", + ); + + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.EnrollAeadOnKeyRotation, + ); + expect(spy).toHaveBeenCalledWith( + mockUser.id, + expect.any(PBKDF2KdfConfig), + mockUser.email, + expect.objectContaining({ version: 1 }), + true, + ); + }); + it("throws if kdf config is null", async () => { KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; @@ -511,17 +620,17 @@ describe("KeyRotationService", () => { }); describe("getNewAccountKeysV1", () => { - const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockEncryptedPrivateKey = new EncString( - "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); - const mockNewEncryptedPrivateKey = new EncString( - "2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); + const currentUserKey = TEST_VECTOR_USER_KEY_V1; + const mockEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey; + const mockNewEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1_ROTATED as WrappedPrivateKey; beforeAll(() => { mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200)); - mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey); - mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400)); + mockEncryptService.wrapDecapsulationKey.mockResolvedValue( + new EncString(mockNewEncryptedPrivateKey), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); }); afterAll(() => { @@ -529,28 +638,110 @@ describe("KeyRotationService", () => { }); it("returns new account keys", async () => { - const result = await keyRotationService.getNewAccountKeysV1( - currentUserKey, - mockEncryptedPrivateKey, - ); + const result = await keyRotationService.getNewAccountKeysV1({ + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: mockEncryptedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); expect(result).toEqual({ userKey: expect.any(SymmetricCryptoKey), - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: mockNewEncryptedPrivateKey, - publicKey: Utils.fromBufferToB64(new Uint8Array(400)), + publicKey: new Uint8Array(400) as UserPublicKey, }, }); }); }); describe("getNewAccountKeysV2", () => { - it("throws not supported", async () => { - await expect( - keyRotationService.getNewAccountKeysV2( - new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, - null, - ), - ).rejects.toThrow("User encryption v2 upgrade is not supported yet"); + it("rotates a v2 user", async () => { + mockGetV2RotatedAccountKeys.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 2 as const, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + ); + expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + }); + it("upgrades v1 user to v2 user", async () => { + mockMakeKeysForUserCryptoV2.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }, + ); + expect(mockMakeKeysForUserCryptoV2).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2), + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); }); }); @@ -560,7 +751,7 @@ describe("KeyRotationService", () => { new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, ); mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); - const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const newKey = TEST_VECTOR_USER_KEY_V1; const userAccount = mockUser; const masterPasswordUnlockData = await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, { @@ -572,13 +763,13 @@ describe("KeyRotationService", () => { expect(masterPasswordUnlockData).toEqual({ masterKeyEncryptedUserKey: "mockNewUserKey", email: "mockEmail", - kdfType: 0, + kdfType: KdfType.PBKDF2_SHA256, kdfIterations: 600_000, masterKeyAuthenticationHash: "mockMasterPasswordHash", masterPasswordHint: "mockMasterPasswordHint", }); expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith( - new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(), + TEST_VECTOR_USER_KEY_V1.toEncoded(), "mockMasterPassword", userAccount.email, new PBKDF2KdfConfig(600_000).toSdkConfig(), @@ -637,8 +828,8 @@ describe("KeyRotationService", () => { masterKeyKdfConfig: new PBKDF2KdfConfig(600_000), masterPasswordHint: "mockMasterPasswordHint", }, - [new Uint8Array(1)], // emergency access public key - [new Uint8Array(2)], // account recovery public key + [new Uint8Array(1) as UnsignedPublicKey], // emergency access public key + [new Uint8Array(2) as UnsignedPublicKey], // account recovery public key ); expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([ { @@ -758,66 +949,29 @@ describe("KeyRotationService", () => { expect(wasTrustDenied).toBe(true); }); - it("returns trusted keys if all dialogs are accepted", async () => { - KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; - EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; - AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; - mockEmergencyAccessService.getPublicKeys.mockResolvedValue([ - mockGranteeEmergencyAccessWithPublicKey, - ]); - mockResetPasswordService.getPublicKeys.mockResolvedValue([ - mockOrganizationUserResetPasswordEntry, - ]); - const { - wasTrustDenied, - trustedOrganizationPublicKeys: trustedOrgs, - trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, - } = await keyRotationService.verifyTrust(mockUser); - expect(wasTrustDenied).toBe(false); - expect(trustedEmergencyAccessUsers).toEqual([ - mockGranteeEmergencyAccessWithPublicKey.publicKey, - ]); - expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]); - }); - }); - - describe("makeNewUserKeyV1", () => { - it("throws if old keys is xchacha20poly1305 key", async () => { - await expect( - keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey), - ).rejects.toThrow( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - }); - it("returns new user key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV1(oldKey); - expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); - }); - }); - - describe("makeNewUserKeyV2", () => { - it("returns xchacha20poly1305 key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70))); - }); - it("returns isUpgrading true if old key is v1", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: true, - }); - }); - it("returns isUpgrading false if old key is v2", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: false, - }); - }); + test.each([ + [[mockGranteeEmergencyAccessWithPublicKey], []], + [[], [mockOrganizationUserResetPasswordEntry]], + [[], []], + [[mockGranteeEmergencyAccessWithPublicKey], [mockOrganizationUserResetPasswordEntry]], + ])( + "returns trusted keys when dialogs are open and public keys are provided", + async (emUsers, orgs) => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + mockEmergencyAccessService.getPublicKeys.mockResolvedValue(emUsers); + mockResetPasswordService.getPublicKeys.mockResolvedValue(orgs); + const { + wasTrustDenied, + trustedOrganizationPublicKeys: trustedOrgs, + trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, + } = await keyRotationService.verifyTrust(mockUser); + expect(wasTrustDenied).toBe(false); + expect(trustedEmergencyAccessUsers).toEqual(emUsers.map((e) => e.publicKey)); + expect(trustedOrgs).toEqual(orgs.map((o) => o.publicKey)); + }, + ); }); describe("getAccountDataRequest", () => { @@ -890,13 +1044,264 @@ describe("KeyRotationService", () => { }); describe("isV1UserKey", () => { - const v1Key = new SymmetricCryptoKey(new Uint8Array(64)); - const v2Key = new SymmetricCryptoKey(new Uint8Array(70)); + const aes256CbcHmacV1UserKey = new SymmetricCryptoKey(new Uint8Array(64)); + const coseV2UserKey = new SymmetricCryptoKey(new Uint8Array(70)); it("returns true for v1 key", () => { - expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true); + expect(keyRotationService.isV1User(aes256CbcHmacV1UserKey as UserKey)).toBe(true); }); it("returns false for v2 key", () => { - expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false); + expect(keyRotationService.isV1User(coseV2UserKey as UserKey)).toBe(false); + }); + it("returns false for 32 byte AES256-CBC key", () => { + const aes256CbcKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + expect(keyRotationService.isV1User(aes256CbcKey)).toBe(false); + }); + }); + + describe("makeServerMasterKeyAuthenticationHash", () => { + it("returns the master key authentication hash", async () => { + mockKeyService.makeMasterKey.mockResolvedValue( + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + ); + mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); + const masterKeyAuthenticationHash = + await keyRotationService.makeServerMasterKeyAuthenticationHash( + "mockMasterPassword", + new PBKDF2KdfConfig(600_000), + "mockEmail", + ); + expect(masterKeyAuthenticationHash).toBe("mockMasterPasswordHash"); + expect(mockKeyService.makeMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + "mockEmail", + new PBKDF2KdfConfig(600_000), + ); + expect(mockKeyService.hashMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + HashPurpose.ServerAuthorization, + ); + }); + }); + + describe("getCryptographicStateForUser", () => { + beforeEach(() => { + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(TEST_VECTOR_USER_KEY_V2)); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V2 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey), + ); + mockSecurityStateService.accountSecurityState$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); + }); + + it("returns the cryptographic state for v1 user", async () => { + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1), + }, + }, + }); + }); + + it("returns the cryptographic state for v2 user", async () => { + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + }); + }); + + it("throws if no kdf config is found", async () => { + mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get KDF config", + ); + }); + + it("throws if current user key is not found", async () => { + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get User key", + ); + }); + + it("throws if private key is not found", async () => { + mockKeyService.userEncryptedPrivateKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get Private key", + ); + }); + + it("throws if user key is not AES256-CBC-HMAC or COSE", async () => { + const invalidKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(invalidKey)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Unsupported user key type", + ); + }); + }); + + describe("getRotatedAccountKeysFlagged", () => { + const userId = "mockUserId" as UserId; + const kdfConfig = new PBKDF2KdfConfig(100000); + const masterKeySalt = "mockSalt"; + const v1Params = { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + } as V1CryptographicStateParameters; + const v2Params = { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + } as V2CryptographicStateParameters; + + beforeEach(() => { + jest.spyOn(keyRotationService, "getNewAccountKeysV1").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1_ROTATED, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); + jest.spyOn(keyRotationService, "getNewAccountKeysV2").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + jest + .spyOn(AccountKeysRequest, "fromV1CryptographicState") + .mockReturnValue("v1Request" as any); + jest + .spyOn(AccountKeysRequest, "fromV2CryptographicState") + .mockResolvedValue("v2Request" as any); + }); + + it("returns v2 keys and request if v2UpgradeEnabled is true", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + true, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v1Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v2 keys and request if params.version is 2", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v2Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v2Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v1 keys and request if v2UpgradeEnabled is false and params.version is 1", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV1).toHaveBeenCalledWith(v1Params); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V1, + accountKeysRequest: "v1Request", + }); + }); + }); + + describe("ensureIsAllowedToRotateUserKey", () => { + it("resolves if last sync exists", async () => { + mockSyncService.getLastSync.mockResolvedValue(new Date()); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).resolves.toBeUndefined(); + }); + + it("throws if last sync is null", async () => { + mockSyncService.getLastSync.mockResolvedValue(null); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).rejects.toThrow( + /de-synced|log out and log back in/i, + ); + expect(mockLogService.info).toHaveBeenCalledWith( + "[Userkey rotation] Client was never synced. Aborting!", + ); }); }); }); diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index f7f611b75e..0980beddd0 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -7,13 +7,21 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; -import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedSecurityState, + UnsignedPublicKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -28,7 +36,7 @@ import { EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth/core"; @@ -39,6 +47,11 @@ import { MasterPasswordUnlockDataRequest } from "./request/master-password-unloc import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { + fromSdkV2KeysToV2UserCryptographicState, + V2UserCryptographicState, +} from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; type MasterPasswordAuthenticationAndUnlockData = { @@ -48,6 +61,19 @@ type MasterPasswordAuthenticationAndUnlockData = { masterPasswordHint: string; }; +/** + * A token provider that exposes a null access token to the SDK. + */ +class NoopTokenProvider implements TokenProvider { + constructor() {} + + async get_access_token(): Promise { + // Ignore from the test coverage, since this is called by the SDK + /* istanbul ignore next */ + return undefined; + } +} + @Injectable({ providedIn: "root" }) export class UserKeyRotationService { constructor( @@ -70,6 +96,8 @@ export class UserKeyRotationService { private configService: ConfigService, private cryptoFunctionService: CryptoFunctionService, private kdfConfigService: KdfConfigService, + private sdkClientFactory: SdkClientFactory, + private securityStateService: SecurityStateService, ) {} /** @@ -85,12 +113,15 @@ export class UserKeyRotationService { user: Account, newMasterPasswordHint?: string, ): Promise { - this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized. + await SdkLoadService.Ready; const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag( FeatureFlag.EnrollAeadOnKeyRotation, ); + this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Make sure all conditions match - e.g. account state is up to date await this.ensureIsAllowedToRotateUserKey(); @@ -104,53 +135,26 @@ export class UserKeyRotationService { } // Read current cryptographic state / settings - const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow( - this.kdfConfigService.getKdfConfig$(user.id), - "KDF config", - ))!; - // The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased. - const masterKeySalt = user.email.trim().toLowerCase(); - const currentUserKey: UserKey = (await firstValueFromOrThrow( - this.keyService.userKey$(user.id), - "User key", - ))!; - const currentUserKeyWrappedPrivateKey = new EncString( - (await firstValueFromOrThrow( - this.keyService.userEncryptedPrivateKey$(user.id), - "User encrypted private key", - ))!, - ); + const { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: currentCryptographicStateParameters, + } = await this.getCryptographicStateForUser(user); - // Update account keys - // This creates at least a new user key, and possibly upgrades user encryption formats - let newUserKey: UserKey; - let wrappedPrivateKey: EncString; - let publicKey: string; - if (upgradeToV2FeatureFlagEnabled) { - this.logService.info("[Userkey rotation] Using v2 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } else { - this.logService.info("[Userkey rotation] Using v1 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } + // Get new set of keys for the account. + const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged( + user.id, + masterKeyKdfConfig, + user.email, + currentCryptographicStateParameters, + upgradeToV2FeatureFlagEnabled, + ); // Assemble the key rotation request const request = new RotateUserAccountKeysRequest( await this.getAccountUnlockDataRequest( user.id, - currentUserKey, + currentCryptographicStateParameters.userKey, newUserKey, { masterPassword: newMasterPassword, @@ -161,8 +165,12 @@ export class UserKeyRotationService { trustedEmergencyAccessUserPublicKeys, trustedOrganizationPublicKeys, ), - new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey), - await this.getAccountDataRequest(currentUserKey, newUserKey, user), + accountKeysRequest, + await this.getAccountDataRequest( + currentCryptographicStateParameters.userKey, + newUserKey, + user, + ), await this.makeServerMasterKeyAuthenticationHash( currentMasterPassword, masterKeyKdfConfig, @@ -194,55 +202,153 @@ export class UserKeyRotationService { } } + async getRotatedAccountKeysFlagged( + userId: UserId, + kdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + v2UpgradeEnabled: boolean, + ): Promise<{ userKey: UserKey; accountKeysRequest: AccountKeysRequest }> { + if (v2UpgradeEnabled || cryptographicStateParameters.version === 2) { + const keys = await this.getNewAccountKeysV2( + userId, + kdfConfig, + masterKeySalt, + cryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: await AccountKeysRequest.fromV2CryptographicState(keys), + }; + } else { + const keys = await this.getNewAccountKeysV1( + cryptographicStateParameters as V1CryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: AccountKeysRequest.fromV1CryptographicState(keys), + }; + } + } + + /** + * This method rotates the user key of a V1 user and re-encrypts the private key. + * @deprecated Removed after roll-out of V2 encryption. + */ protected async getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - // Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key. + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Account key rotation creates a new user key. All downstream data and keys need to be re-encrypted under this key. // Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the // creation of a new signing key pair. - const newUserKey = await this.makeNewUserKeyV1(currentUserKey); + const newUserKey = new SymmetricCryptoKey( + PureCrypto.make_user_key_aes256_cbc_hmac(), + ) as UserKey; // Re-encrypt the private key with the new user key // Rotation of the private key is not supported yet const privateKey = await this.encryptService.unwrapDecapsulationKey( - currentUserKeyWrappedPrivateKey, - currentUserKey, + new EncString(cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey), + cryptographicStateParameters.userKey, ); - const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey( + const newUserKeyWrappedPrivateKey = ( + await this.encryptService.wrapDecapsulationKey(privateKey, newUserKey) + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( privateKey, - newUserKey, - ); - const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + )) as UnsignedPublicKey; return { userKey: newUserKey, - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: newUserKeyWrappedPrivateKey, - publicKey: Utils.fromBufferToB64(publicKey), + publicKey: publicKey, }, }; } + /** + * This method either enrolls a user from v1 encryption to v2 encryption, rotating the user key, or rotates the keys of a v2 user, staying on v2. + */ protected async getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - throw new Error("User encryption v2 upgrade is not supported yet"); + userId: UserId, + masterKeyKdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + if (cryptographicStateParameters.version === 1) { + return this.upgradeV1UserToV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V1CryptographicStateParameters, + ); + } else { + return this.rotateV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V2CryptographicStateParameters, + ); + } } + /** + * Upgrades a V1 user to a V2 user by creating a new user key, re-encrypting the private key, generating a signature key-pair, and + * finally creating a signed security state. + */ + protected async upgradeV1UserToV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: undefined, + securityState: undefined, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().make_keys_for_user_crypto_v2()); + } + + /** + * Generates a new user key for a v2 user, and re-encrypts the private key, signing key. + */ + protected async rotateV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V2CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: cryptographicStateParameters.signingKey, + securityState: cryptographicStateParameters.securityState, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().get_v2_rotated_account_keys()); + } + + /** + * Generates a new request for updating the master-password unlock/authentication data. + */ protected async createMasterPasswordUnlockDataRequest( userKey: UserKey, newUnlockData: MasterPasswordAuthenticationAndUnlockData, @@ -272,13 +378,17 @@ export class UserKeyRotationService { ); } + /** + * Re-generates the accounts unlock methods, including master-password, passkey, trusted device, emergency access, and organization account recovery + * for the new user key. + */ protected async getAccountUnlockDataRequest( userId: UserId, currentUserKey: UserKey, newUserKey: UserKey, masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { // To ensure access; all unlock methods need to be updated and provided the new user key. // User unlock methods @@ -321,10 +431,13 @@ export class UserKeyRotationService { ); } + /** + * Verifies the trust of the organizations and emergency access users by prompting the user. Denying any of these will return early. + */ protected async verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { // Since currently the joined organizations and emergency access grantees are // not signed, manual trust prompts are required, to verify that the server @@ -392,11 +505,16 @@ export class UserKeyRotationService { ); return { wasTrustDenied: false, - trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey), - trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey), + trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey as UnsignedPublicKey), + trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map( + (d) => d.publicKey as UnsignedPublicKey, + ), }; } + /** + * Re-encrypts the account data owned by the user, such as ciphers, folders, and sends with the new user key. + */ protected async getAccountDataRequest( originalUserKey: UserKey, newUnencryptedUserKey: UserKey, @@ -429,64 +547,6 @@ export class UserKeyRotationService { return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends); } - protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - if (this.isV1User(oldUserKey)) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading", - ); - return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey; - } else { - // If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account. - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..", - ); - throw new Error( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - } - } - - protected async makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - const newUserKey: UserKey = new SymmetricCryptoKey( - PureCrypto.make_user_key_xchacha20_poly1305(), - ) as UserKey; - const isUpgrading = this.isV1User(oldUserKey); - if (isUpgrading) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305", - ); - } else { - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed", - ); - } - return { isUpgrading, newUserKey }; - } - /** * A V1 user has no signing key, and uses AES256-CBC-HMAC. * A V2 user has a signing key, and uses XChaCha20-Poly1305. @@ -516,4 +576,111 @@ export class UserKeyRotationService { HashPurpose.ServerAuthorization, ); } + + /** + * Gets the cryptographic state for a user. This can be a V1 user or a V2 user. + */ + protected async getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + // Master password unlock + const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow( + this.kdfConfigService.getKdfConfig$(user.id), + "KDF config", + ))!; + // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased. + const masterKeySalt = user.email.trim().toLowerCase(); + + // V1 and V2 users both have a user key and a private key + const currentUserKey: UserKey = (await this.firstValueFromOrThrow( + this.keyService.userKey$(user.id), + "User key", + ))!; + const currentUserKeyWrappedPrivateKey: WrappedPrivateKey = new EncString( + (await this.firstValueFromOrThrow( + this.keyService.userEncryptedPrivateKey$(user.id), + "Private key", + ))!, + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( + await this.encryptService.unwrapDecapsulationKey( + new EncString(currentUserKeyWrappedPrivateKey), + currentUserKey, + ), + )) as UnsignedPublicKey; + + if (this.isV1User(currentUserKey)) { + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + }, + }; + } else if (currentUserKey.inner().type === EncryptionType.CoseEncrypt0) { + const signingKey = await this.firstValueFromOrThrow( + this.keyService.userSigningKey$(user.id), + "User signing key", + ); + const securityState = await this.firstValueFromOrThrow( + this.securityStateService.accountSecurityState$(user.id), + "User security state", + ); + + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 2, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + signingKey: signingKey!, + securityState: securityState!, + }, + }; + } + + /// AES-CBC (no-hmac) keys are not supported as user keys + throw new Error( + `Unsupported user key type: ${currentUserKey.inner().type}. Expected AesCbc256_HmacSha256_B64 or XChaCha20_Poly1305_B64.`, + ); + } + + async firstValueFromOrThrow(value: Observable, name: string): Promise { + const result = await firstValueFrom(value); + if (result == null) { + throw new Error(`Failed to get ${name}`); + } + return result as T; + } } + +export type V1CryptographicStateParameters = { + version: 1; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; + +export type V2CryptographicStateParameters = { + version: 2; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; + signingKey: WrappedSigningKey; + securityState: SignedSecurityState; +}; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 15f52d0e65..717a6c501c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -170,6 +170,8 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; +import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; +import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, @@ -177,6 +179,8 @@ import { import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { SendPasswordService, DefaultSendPasswordService, @@ -702,6 +706,11 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, ], }), + safeProvider({ + provide: SecurityStateService, + useClass: DefaultSecurityStateService, + deps: [StateProvider], + }), safeProvider({ provide: RestrictedItemTypesService, useClass: RestrictedItemTypesService, @@ -797,6 +806,11 @@ const safeProviders: SafeProvider[] = [ useClass: SendApiService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), + safeProvider({ + provide: KeyApiService, + useClass: DefaultKeyApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: SyncService, useClass: DefaultSyncService, @@ -825,6 +839,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, AuthServiceAbstraction, StateProvider, + SecurityStateService, ], }), safeProvider({ @@ -1523,6 +1538,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, KdfConfigService, KeyService, + SecurityStateService, ApiServiceAbstraction, StateProvider, ConfigService, diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 175da71680..11c186bc39 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -8,6 +8,7 @@ import { } from "../../../platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../../types/csprng"; +import { UnsignedPublicKey } from "../../types"; import { CryptoFunctionService } from "../abstractions/crypto-function.service"; export class WebCryptoFunctionService implements CryptoFunctionService { @@ -309,7 +310,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService { "encrypt", ]); const buffer = await this.subtle.exportKey("spki", impPublicKey); - return new Uint8Array(buffer); + return new Uint8Array(buffer) as UnsignedPublicKey; } async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { diff --git a/libs/common/src/key-management/enums/signing-key-type.enum.ts b/libs/common/src/key-management/enums/signing-key-type.enum.ts new file mode 100644 index 0000000000..b1370ef003 --- /dev/null +++ b/libs/common/src/key-management/enums/signing-key-type.enum.ts @@ -0,0 +1,13 @@ +export const SigningKeyTypes = { + Ed25519: "ed25519", +} as const; + +export type SigningKeyType = (typeof SigningKeyTypes)[keyof typeof SigningKeyTypes]; +export function parseSigningKeyTypeFromString(value: string): SigningKeyType { + switch (value) { + case SigningKeyTypes.Ed25519: + return SigningKeyTypes.Ed25519; + default: + throw new Error(`Unknown signing key type: ${value}`); + } +} diff --git a/libs/common/src/key-management/keys/response/private-keys.response.ts b/libs/common/src/key-management/keys/response/private-keys.response.ts new file mode 100644 index 0000000000..2bd723fb45 --- /dev/null +++ b/libs/common/src/key-management/keys/response/private-keys.response.ts @@ -0,0 +1,55 @@ +import { SecurityStateResponse } from "../../security-state/response/security-state.response"; + +import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response"; +import { SignatureKeyPairResponse } from "./signature-key-pair.response"; + +/** + * The privately accessible view of an entity (account / org)'s keys. + * This includes the full key-pairs for public-key encryption and signing, as well as the security state if available. + */ +export class PrivateKeysResponseModel { + readonly publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairResponse; + readonly signatureKeyPair: SignatureKeyPairResponse | null = null; + readonly securityState: SecurityStateResponse | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if ( + !("publicKeyEncryptionKeyPair" in response) || + typeof response.publicKeyEncryptionKeyPair !== "object" + ) { + throw new TypeError("Response must contain a valid publicKeyEncryptionKeyPair"); + } + this.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairResponse( + response.publicKeyEncryptionKeyPair, + ); + + if ( + "signatureKeyPair" in response && + typeof response.signatureKeyPair === "object" && + response.signatureKeyPair != null + ) { + this.signatureKeyPair = new SignatureKeyPairResponse(response.signatureKeyPair); + } + + if ( + "securityState" in response && + typeof response.securityState === "object" && + response.securityState != null + ) { + this.securityState = new SecurityStateResponse(response.securityState); + } + + if ( + (this.signatureKeyPair !== null && this.securityState === null) || + (this.signatureKeyPair === null && this.securityState !== null) + ) { + throw new TypeError( + "Both signatureKeyPair and securityState must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts new file mode 100644 index 0000000000..4b1e3f90e2 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts @@ -0,0 +1,32 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { SignedPublicKey, UnsignedPublicKey, WrappedPrivateKey } from "../../types"; + +export class PublicKeyEncryptionKeyPairResponse { + readonly wrappedPrivateKey: WrappedPrivateKey; + readonly publicKey: UnsignedPublicKey; + + readonly signedPublicKey: SignedPublicKey | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || typeof response.publicKey !== "string") { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey; + + if (!("wrappedPrivateKey" in response) || typeof response.wrappedPrivateKey !== "string") { + throw new TypeError("Response must contain a valid wrappedPrivateKey"); + } + this.wrappedPrivateKey = response.wrappedPrivateKey as WrappedPrivateKey; + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-keys.response.ts b/libs/common/src/key-management/keys/response/public-keys.response.ts new file mode 100644 index 0000000000..cf4b3efc34 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-keys.response.ts @@ -0,0 +1,44 @@ +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +import { UnsignedPublicKey, VerifyingKey } from "../../types"; + +/** + * The publicly accessible view of an entity (account / org)'s keys. That includes the encryption public key, and the verifying key if available. + */ +export class PublicKeysResponseModel { + readonly publicKey: UnsignedPublicKey; + readonly verifyingKey: VerifyingKey | null; + readonly signedPublicKey?: SignedPublicKey | null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || !(response.publicKey instanceof Uint8Array)) { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = response.publicKey as UnsignedPublicKey; + + if ("verifyingKey" in response && typeof response.verifyingKey === "string") { + this.verifyingKey = response.verifyingKey as VerifyingKey; + } else { + this.verifyingKey = null; + } + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + + if ( + (this.signedPublicKey !== null && this.verifyingKey === null) || + (this.signedPublicKey === null && this.verifyingKey !== null) + ) { + throw new TypeError( + "Both signedPublicKey and verifyingKey must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/signature-key-pair.response.ts b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts new file mode 100644 index 0000000000..2499839b64 --- /dev/null +++ b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts @@ -0,0 +1,22 @@ +import { VerifyingKey, WrappedSigningKey } from "../../types"; + +export class SignatureKeyPairResponse { + readonly wrappedSigningKey: WrappedSigningKey; + readonly verifyingKey: VerifyingKey; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("wrappedSigningKey" in response) || typeof response.wrappedSigningKey !== "string") { + throw new TypeError("Response must contain a valid wrappedSigningKey"); + } + this.wrappedSigningKey = response.wrappedSigningKey as WrappedSigningKey; + + if (!("verifyingKey" in response) || typeof response.verifyingKey !== "string") { + throw new TypeError("Response must contain a valid verifyingKey"); + } + this.verifyingKey = response.verifyingKey as VerifyingKey; + } +} diff --git a/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts new file mode 100644 index 0000000000..93556dbb57 --- /dev/null +++ b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts @@ -0,0 +1,5 @@ +import { PublicKeysResponseModel } from "../../response/public-keys.response"; + +export abstract class KeyApiService { + abstract getUserPublicKeys(id: string): Promise; +} diff --git a/libs/common/src/key-management/keys/services/default-key-api-service.service.ts b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts new file mode 100644 index 0000000000..fd8321055b --- /dev/null +++ b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts @@ -0,0 +1,15 @@ +import { UserId } from "@bitwarden/common/types/guid"; + +import { ApiService } from "../../../abstractions/api.service"; +import { PublicKeysResponseModel } from "../response/public-keys.response"; + +import { KeyApiService } from "./abstractions/key-api-service.abstraction"; + +export class DefaultKeyApiService implements KeyApiService { + constructor(private apiService: ApiService) {} + + async getUserPublicKeys(id: UserId): Promise { + const response = await this.apiService.send("GET", "/users/" + id + "/keys", null, true, true); + return new PublicKeysResponseModel(response); + } +} diff --git a/libs/common/src/key-management/security-state/abstractions/security-state.service.ts b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts new file mode 100644 index 0000000000..466095c2f4 --- /dev/null +++ b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts @@ -0,0 +1,21 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; + +export abstract class SecurityStateService { + /** + * Retrieves the security state for the provided user. + * Note: This state is not yet validated. To get a validated state, the SDK crypto client + * must be used. This security state is validated on initialization of the SDK. + */ + abstract accountSecurityState$(userId: UserId): Observable; + /** + * Sets the security state for the provided user. + */ + abstract setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise; +} diff --git a/libs/common/src/key-management/security-state/request/security-state.request.ts b/libs/common/src/key-management/security-state/request/security-state.request.ts new file mode 100644 index 0000000000..7c825bedf8 --- /dev/null +++ b/libs/common/src/key-management/security-state/request/security-state.request.ts @@ -0,0 +1,8 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateRequest { + constructor( + readonly securityState: SignedSecurityState, + readonly securityVersion: number, + ) {} +} diff --git a/libs/common/src/key-management/security-state/response/security-state.response.ts b/libs/common/src/key-management/security-state/response/security-state.response.ts new file mode 100644 index 0000000000..0590da3191 --- /dev/null +++ b/libs/common/src/key-management/security-state/response/security-state.response.ts @@ -0,0 +1,16 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateResponse { + readonly securityState: SignedSecurityState | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("securityState" in response) || !(typeof response.securityState === "string")) { + throw new TypeError("Response must contain a valid securityState"); + } + this.securityState = response.securityState as SignedSecurityState; + } +} diff --git a/libs/common/src/key-management/security-state/services/security-state.service.ts b/libs/common/src/key-management/security-state/services/security-state.service.ts new file mode 100644 index 0000000000..789d517107 --- /dev/null +++ b/libs/common/src/key-management/security-state/services/security-state.service.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; +import { SecurityStateService } from "../abstractions/security-state.service"; +import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state"; + +export class DefaultSecurityStateService implements SecurityStateService { + constructor(protected stateProvider: StateProvider) {} + + // Emits the provided user's security state, or null if there is no security state present for the user. + accountSecurityState$(userId: UserId): Observable { + return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId); + } + + // Sets the security state for the provided user. + // This is not yet validated, and is only validated upon SDK initialization. + async setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise { + await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId); + } +} diff --git a/libs/common/src/key-management/security-state/state/security-state.state.ts b/libs/common/src/key-management/security-state/state/security-state.state.ts new file mode 100644 index 0000000000..e471ef17d7 --- /dev/null +++ b/libs/common/src/key-management/security-state/state/security-state.state.ts @@ -0,0 +1,12 @@ +import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { SignedSecurityState } from "../../types"; + +export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition( + CRYPTO_DISK, + "accountSecurityState", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/key-management/types.ts b/libs/common/src/key-management/types.ts new file mode 100644 index 0000000000..df64a3ed34 --- /dev/null +++ b/libs/common/src/key-management/types.ts @@ -0,0 +1,30 @@ +import { Opaque } from "type-fest"; + +import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal"; + +/** + * A private key, encrypted with a symmetric key. + */ +export type WrappedPrivateKey = Opaque; + +/** + * A public key, signed with the accounts signature key. + */ +export type SignedPublicKey = Opaque; +/** + * A public key in base64 encoded SPKI-DER + */ +export type UnsignedPublicKey = Opaque; + +/** + * A signature key encrypted with a symmetric key. + */ +export type WrappedSigningKey = Opaque; +/** + * A signature public key (verifying key) in base64 encoded CoseKey format + */ +export type VerifyingKey = Opaque; +/** + * A signed security state, encoded in base64. + */ +export type SignedSecurityState = Opaque; diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index a6982d7ed4..11aca73d5c 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -1,3 +1,5 @@ +import { PrivateKeysResponseModel } from "@bitwarden/common/key-management/keys/response/private-keys.response"; + import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; @@ -18,7 +20,10 @@ export class ProfileResponse extends BaseResponse { key?: EncString; avatarColor: string; creationDate: string; + // Cleanup: Can be removed after moving to accountKeys privateKey: string; + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + accountKeys: PrivateKeysResponseModel | null = null; securityStamp: string; forcePasswordReset: boolean; usesKeyConnector: boolean; @@ -37,10 +42,16 @@ export class ProfileResponse extends BaseResponse { this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization"); this.culture = this.getResponseProperty("Culture"); this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); + const key = this.getResponseProperty("Key"); if (key) { this.key = new EncString(key); } + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + if (this.getResponseProperty("AccountKeys") != null) { + this.accountKeys = new PrivateKeysResponseModel(this.getResponseProperty("AccountKeys")); + } + this.avatarColor = this.getResponseProperty("AvatarColor"); this.creationDate = this.getResponseProperty("CreationDate"); this.privateKey = this.getResponseProperty("PrivateKey"); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index 1b88554e53..2416c211d6 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -1,4 +1,5 @@ import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "../../../key-management/types"; import { UserKey } from "../../../types/key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; @@ -25,3 +26,12 @@ export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, clearOn: ["logout", "lock"], }); + +export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition( + CRYPTO_DISK, + "userSigningKey", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 7165e84588..4aee0d48e5 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -1,7 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of } from "rxjs"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -18,6 +18,7 @@ import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; +import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; @@ -43,6 +44,7 @@ describe("DefaultSdkService", () => { let platformUtilsService!: MockProxy; let kdfConfigService!: MockProxy; let keyService!: MockProxy; + let securityStateService!: MockProxy; let configService!: MockProxy; let service!: DefaultSdkService; let accountService!: FakeAccountService; @@ -57,6 +59,7 @@ describe("DefaultSdkService", () => { platformUtilsService = mock(); kdfConfigService = mock(); keyService = mock(); + securityStateService = mock(); apiService = mock(); const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); @@ -75,6 +78,7 @@ describe("DefaultSdkService", () => { accountService, kdfConfigService, keyService, + securityStateService, apiService, fakeStateProvider, configService, @@ -100,6 +104,8 @@ describe("DefaultSdkService", () => { .calledWith(userId) .mockReturnValue(of("private-key" as EncryptedString)); keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({})); + keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null)); + securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null)); }); describe("given no client override has been set for the user", () => { diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index ec57783e02..6f9c9df761 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -31,6 +31,8 @@ import { ApiService } from "../../../abstractions/api.service"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { DeviceType } from "../../../enums/device-type.enum"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; +import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service"; +import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types"; import { OrganizationId, UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -98,6 +100,7 @@ export class DefaultSdkService implements SdkService { private accountService: AccountService, private kdfConfigService: KdfConfigService, private keyService: KeyService, + private securityStateService: SecurityStateService, private apiService: ApiService, private stateProvider: StateProvider, private configService: ConfigService, @@ -160,10 +163,14 @@ export class DefaultSdkService implements SdkService { const privateKey$ = this.keyService .userEncryptedPrivateKey$(userId) .pipe(distinctUntilChanged()); + const signingKey$ = this.keyService.userSigningKey$(userId).pipe(distinctUntilChanged()); const userKey$ = this.keyService.userKey$(userId).pipe(distinctUntilChanged()); const orgKeys$ = this.keyService.encryptedOrgKeys$(userId).pipe( distinctUntilChanged(compareValues), // The upstream observable emits different objects with the same values ); + const securityState$ = this.securityStateService + .accountSecurityState$(userId) + .pipe(distinctUntilChanged(compareValues)); const client$ = combineLatest([ this.environmentService.getEnvironment$(userId), @@ -171,51 +178,57 @@ export class DefaultSdkService implements SdkService { kdfParams$, privateKey$, userKey$, + signingKey$, orgKeys$, + securityState$, SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded ]).pipe( // switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value. - switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => { - // Create our own observable to be able to implement clean-up logic - return new Observable>((subscriber) => { - const createAndInitializeClient = async () => { - if (env == null || kdfParams == null || privateKey == null || userKey == null) { - return undefined; - } + switchMap( + ([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => { + // Create our own observable to be able to implement clean-up logic + return new Observable>((subscriber) => { + const createAndInitializeClient = async () => { + if (env == null || kdfParams == null || privateKey == null || userKey == null) { + return undefined; + } - const settings = this.toSettings(env); - const client = await this.sdkClientFactory.createSdkClient( - new JsTokenProvider(this.apiService, userId), - settings, - ); + const settings = this.toSettings(env); + const client = await this.sdkClientFactory.createSdkClient( + new JsTokenProvider(this.apiService, userId), + settings, + ); - await this.initializeClient( - userId, - client, - account, - kdfParams, - privateKey, - userKey, - orgKeys, - ); + await this.initializeClient( + userId, + client, + account, + kdfParams, + privateKey, + userKey, + signingKey, + securityState, + orgKeys, + ); - return client; - }; + return client; + }; - let client: Rc | undefined; - createAndInitializeClient() - .then((c) => { - client = c === undefined ? undefined : new Rc(c); + let client: Rc | undefined; + createAndInitializeClient() + .then((c) => { + client = c === undefined ? undefined : new Rc(c); - subscriber.next(client); - }) - .catch((e) => { - subscriber.error(e); - }); + subscriber.next(client); + }) + .catch((e) => { + subscriber.error(e); + }); - return () => client?.markForDisposal(); - }); - }), + return () => client?.markForDisposal(); + }); + }, + ), tap({ finalize: () => this.sdkClientCache.delete(userId) }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -231,6 +244,8 @@ export class DefaultSdkService implements SdkService { kdfParams: KdfConfig, privateKey: EncryptedString, userKey: UserKey, + signingKey: WrappedSigningKey | null, + securityState: SignedSecurityState | null, orgKeys: Record, ) { await client.crypto().initialize_user_crypto({ @@ -248,8 +263,8 @@ export class DefaultSdkService implements SdkService { }, }, privateKey, - signingKey: undefined, - securityState: undefined, + signingKey: signingKey || undefined, + securityState: securityState || undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 193a5a2d2d..f60b42ce45 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -72,6 +74,7 @@ describe("DefaultSyncService", () => { let tokenService: MockProxy; let authService: MockProxy; let stateProvider: MockProxy; + let securityStateService: MockProxy; let sut: DefaultSyncService; @@ -101,6 +104,7 @@ describe("DefaultSyncService", () => { tokenService = mock(); authService = mock(); stateProvider = mock(); + securityStateService = mock(); sut = new DefaultSyncService( masterPasswordAbstraction, @@ -127,6 +131,7 @@ describe("DefaultSyncService", () => { tokenService, authService, stateProvider, + securityStateService, ); }); @@ -155,6 +160,142 @@ describe("DefaultSyncService", () => { stateProvider.getUser.mockReturnValue(mock()); }); + it("sets the correct keys for a V1 user with old response model", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V1 user", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V2 user", async () => { + const v2Profile = { + id: user1, + key: "encryptedUserKey", + providers: [] as unknown[], + organizations: [] as unknown[], + providerOrganizations: [] as unknown[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + privateKey: "wrappedPrivateKey", + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + signedPublicKey: "signedPublicKey", + }, + signatureKeyPair: { + wrappedSigningKey: "wrappedSigningKey", + verifyingKey: "verifyingKey", + }, + securityState: { + securityState: "securityState", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v2Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1); + expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( + "securityState", + user1, + ); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + it("does a token refresh when option missing from options", async () => { await sut.fullSync(true, { allowThrowOnError: false }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a02d602dbf..d5fa2d0ae6 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -10,6 +10,7 @@ import { CollectionService, } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -98,6 +99,7 @@ export class DefaultSyncService extends CoreSyncService { tokenService: TokenService, authService: AuthService, stateProvider: StateProvider, + private securityStateService: SecurityStateService, ) { super( tokenService, @@ -233,13 +235,34 @@ export class DefaultSyncService extends CoreSyncService { if (response?.key) { await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id); } - await this.keyService.setPrivateKey(response.privateKey, response.id); + + // Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768 + if (response.accountKeys != null) { + await this.keyService.setPrivateKey( + response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey, + response.id, + ); + if (response.accountKeys.signatureKeyPair !== null) { + // User is V2 user + await this.keyService.setUserSigningKey( + response.accountKeys.signatureKeyPair.wrappedSigningKey, + response.id, + ); + await this.securityStateService.setAccountSecurityState( + response.accountKeys.securityState.securityState, + response.id, + ); + } + } else { + await this.keyService.setPrivateKey(response.privateKey, response.id); + } await this.keyService.setProviderKeys(response.providers, response.id); await this.keyService.setOrgKeys( response.organizations, response.providerOrganizations, response.id, ); + await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts index 8984452e70..ca56deb2fb 100644 --- a/libs/common/src/types/key.ts +++ b/libs/common/src/types/key.ts @@ -1,5 +1,6 @@ import { Opaque } from "type-fest"; +import { UnsignedPublicKey } from "../key-management/types"; import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key"; // symmetric keys @@ -15,4 +16,4 @@ export type CipherKey = Opaque; // asymmetric keys export type UserPrivateKey = Opaque; -export type UserPublicKey = Opaque; +export type UserPublicKey = Opaque; diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index c6c751bf25..e4bb83cb2f 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -7,6 +7,7 @@ import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -236,8 +237,10 @@ export abstract class KeyService { */ abstract getOrgKey(orgId: string): Promise; /** - * Uses the org key to derive a new symmetric key for encrypting data - * @param key The organization's symmetric key + * Makes a fresh attachment content encryption key and returns it along with a wrapped (encrypted) version of it. + * @deprecated Do not use this for new code / new cryptographic designs. + * @param key The organization's symmetric key or the user's user key to wrap the attachment key with + * @returns The new attachment content encryption key and the wrapped version of it */ abstract makeDataEncKey( key: T, @@ -272,6 +275,14 @@ export abstract class KeyService { * @param encPrivateKey An encrypted private key */ abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise; + /** + * Sets the user's encrypted signing key in storage + * In contrast to the private key, the decrypted signing key + * is not stored in memory outside of the SDK. + * @param encryptedSigningKey An encrypted signing key + * @param userId The user id of the user to set the signing key for + */ + abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -416,7 +427,13 @@ export abstract class KeyService { * * @throws If an invalid user id is passed in. */ - abstract userPublicKey$(userId: UserId): Observable; + abstract userPublicKey$(userId: UserId): Observable; + + /** + * Gets a users signing keys from local state. + * The observable will emit null, exactly if the local state returns null. + */ + abstract userSigningKey$(userId: UserId): Observable; /** * Validates that a userkey is correct for a given user diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 52fecf26c7..46d1125711 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -11,6 +11,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,6 +26,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { @@ -432,6 +434,7 @@ describe("keyService", () => { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ENCRYPTED_PROVIDER_KEYS, USER_ENCRYPTED_PRIVATE_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, USER_KEY, ])("key removal", (key: UserKeyDefinition) => { it(`clears ${key.key} for the specified user when specified`, async () => { @@ -540,6 +543,51 @@ describe("keyService", () => { }); }); + describe("userSigningKey$", () => { + it("returns the signing key when the user has a signing key set", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(fakeSigningKey); + + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toEqual(fakeSigningKey); + }); + + it("returns null when the user does not have a signing key set", async () => { + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toBeFalsy(); + }); + }); + + describe("setUserSigningKey", () => { + it("throws if the signing key is null", async () => { + await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow( + "No user signing key provided.", + ); + }); + it("throws if the userId is null", async () => { + await expect( + keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId), + ).rejects.toThrow("No userId provided."); + }); + it("sets the signing key for the user", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(null); + await keyService.setUserSigningKey(fakeSigningKey, mockUserId); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey); + }); + }); + describe("cipherDecryptionKeys$", () => { function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) { const output = new Uint8Array(64); @@ -1132,12 +1180,12 @@ describe("keyService", () => { keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key")); cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( - Utils.fromUtf8ToArray("public key"), + Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, ); const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); expect(key).toEqual({ privateKey: "private key", - publicKey: Utils.fromUtf8ToArray("public key"), + publicKey: Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, }); }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index cdf7d594f2..a13c74e96d 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -28,6 +28,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -44,6 +45,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CsprngArray } from "@bitwarden/common/types/csprng"; @@ -398,8 +400,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { throw new Error("No key provided"); } - const newSymKey = await this.keyGenerationService.createKey(512); - return this.buildProtectedSymmetricKey(key, newSymKey); + // Content encryption key is AES256_CBC_HMAC + const cek = await this.keyGenerationService.createKey(512); + const wrappedCek = await this.encryptService.wrapSymmetricKey(cek, key); + return [cek, wrappedCek]; } private async clearOrgKeys(userId: UserId): Promise { @@ -505,6 +509,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); } + private async clearSigningKey(userId: UserId): Promise { + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId); + } + async clearPinKeys(userId: UserId): Promise { if (userId == null) { throw new Error("UserId is required"); @@ -537,6 +545,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.clearOrgKeys(userId); await this.clearProviderKeys(userId); await this.clearKeyPair(userId); + await this.clearSigningKey(userId); await this.clearPinKeys(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); } @@ -758,6 +767,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { return phrase; } + /** + * @deprecated + * This should only be used for wrapping the user key with a master key or stretched master key. + */ private async buildProtectedSymmetricKey( encryptionKey: SymmetricCryptoKey, newSymKey: SymmetricCryptoKey, @@ -792,7 +805,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - return (await this.cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; + return await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); } userPrivateKey$(userId: UserId): Observable { @@ -808,7 +821,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - const publicKey = (await this.derivePublicKey(privateKey))!; + const publicKey = (await this.derivePublicKey(privateKey))! as UserPublicKey; return { privateKey, publicKey }; }), ); @@ -905,6 +918,27 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } + async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise { + if (userSigningKey == null) { + throw new Error("No user signing key provided."); + } + if (userId == null) { + throw new Error("No userId provided."); + } + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId); + } + + userSigningKey$(userId: UserId): Observable { + return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe( + map((encryptedSigningKey) => { + if (encryptedSigningKey == null) { + return null; + } + return encryptedSigningKey as WrappedSigningKey; + }), + ); + } + orgKeys$(userId: UserId): Observable | null> { return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null)); } diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts index f3ac71b13e..22cc5756f3 100644 --- a/libs/node/src/services/node-crypto-function.service.ts +++ b/libs/node/src/services/node-crypto-function.service.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; import * as forge from "node-forge"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { UnsignedPublicKey } from "@bitwarden/common/key-management/types"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { @@ -232,7 +233,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { return Promise.resolve(this.toUint8Buffer(decipher)); } - rsaExtractPublicKey(privateKey: Uint8Array): Promise { + async rsaExtractPublicKey(privateKey: Uint8Array): Promise { const privateKeyByteString = Utils.fromBufferToByteString(privateKey); const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString); const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1); @@ -240,11 +241,11 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey); const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data; const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString); - return Promise.resolve(publicKeyArray); + return publicKeyArray as UnsignedPublicKey; } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> { - return new Promise<[Uint8Array, Uint8Array]>((resolve, reject) => { + async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> { + return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => { forge.pki.rsa.generateKeyPair( { bits: length, @@ -266,7 +267,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes(); const privateKey = Utils.fromByteStringToArray(privateKeyByteString); - resolve([publicKey, privateKey]); + resolve([publicKey as UnsignedPublicKey, privateKey]); }, ); });