From 2a5a30844dfa8242dada135d263ccbfd9de8f991 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 12 Jul 2023 12:14:24 -0400 Subject: [PATCH] Auth/[PM-1260] - Existing User - Login with Trusted Device (Flow 2) (#5775) * PM-1378 - Refactor - StateSvc.getDeviceKey() must actually convert JSON obj into instance of SymmetricCryptoKey * TODO: BaseLoginDecryptionOptionsComponent - verify new user check doesn't improperly pick up key connector users * PM-1260 - Add new encrypted keys to TrustedDeviceUserDecryptionOptionResponse * PM-1260 - DeviceTrustCryptoSvc - decryptUserKeyWithDeviceKey: (1) update method to optionally accept deviceKey (2) Return null user key when no device key exists (3) decryption of user key now works in the happy path * PM-1260 - LoginStrategy - SaveAcctInfo - Must persist device key on new account entity created from IdTokenResponse for TDE to work * PM-1260 - SSO Login Strategy - setUserKey refactor - (1) Refactor existing logic into trySetUserKeyForKeyConnector + setUserKeyMasterKey call and (2) new trySetUserKeyWithDeviceKey method for TDE * PM-1260 - Refactor DeviceTrustCryptoService.decryptUserKeyWithDeviceKey(...) - Add try catch around decryption attempts which removes device key (and trust) on decryption failure + warn. * PM-1260 - Account - Add deviceKey to fromJSON * TODO: add device key tests to account keys * TODO: figure out state service issues with getDeviceKey or if they are an issue w/ the account deserialization as a whole * PM-1260 - Add test suite for decryptUserKeyWithDeviceKey * PM-1260 - Add interfaces for server responses for UserDecryptionOptions to make testing easier without having to use the dreaded any type. * PM-1260 - SSOLoginStrategy - SetUserKey - Add check looking for key connector url on user decryption options + comment about future deprecation of tokenResponse.keyConnectorUrl * PM-1260 - SSO Login Strategy Spec file - Add test suite for TDE set user key logic * PM-1260 - BaseLoginStrategy - add test to verify device key persists on login * PM-1260 - StateService - verified that settings persist properly post SSO and it's just device keys we must manually instantiate into SymmetricCryptoKeys * PM-1260 - Remove comment about being unable to feature flag auth service / login strategy code due to circ deps as we don't need to worry about it b/c of the way we've written the new logic to be additive. * PM-1260 - DevicesApiServiceImplementation - Update constructor to properly use abstraction for API service * PM-1260 - Browser - AuthService - (1) Add new, required service factories for auth svc and (2) Update auth svc creation in main.background with new deps * PM-1260 - CLI - Update AuthSvc deps * PM-1260 - Address PR feedback to add clarity / match conventions * PM-1260 - Resolving more minor PR feedback * PM-1260 - DeviceTrustCryptoService - remove debug warn * PM-1378 - DeviceTrustCryptoSvc - TrustDevice - Fix bug where we only partially encrypted the user key with the device public key b/c I incorrectly passed userKey.encKey (32 bytes) instead of userKey.key (64 bytes) to the rsaEncrypt function which lead to an encryption type mismatch when decrypting the user's private key with the 32 byte decrypted user key obtained after TDE login. (Updated happy path test to prevent this from happening again) * PM-1260 - AccountKeys tests - add tests for deviceKey persistence and deserialization * PM-1260 - DeviceTrustCryptoSvc Test - tweak verbiage per feedback * PM-1260 - DeviceTrustCryptoSvc - Test verbiage tweak part 2 * Update apps/browser/src/background/service-factories/devices-api-service.factory.ts per PR feedback Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- .../service-factories/auth-service.factory.ts | 4 +- .../device-trust-crypto-service.factory.ts | 62 ++++++++ .../browser/src/background/main.background.ts | 20 ++- .../devices-api-service.factory.ts | 28 ++++ apps/cli/src/bw.ts | 19 ++- ...base-login-decryption-options.component.ts | 3 + .../src/services/jslib-services.module.ts | 1 + ...device-trust-crypto.service.abstraction.ts | 9 +- .../login-strategies/login.strategy.spec.ts | 55 ++++++- .../auth/login-strategies/login.strategy.ts | 16 +- .../sso-login.strategy.spec.ts | 142 +++++++++++++++++- .../login-strategies/sso-login.strategy.ts | 93 ++++++++---- ...nnector-user-decryption-option.response.ts | 6 +- ...-device-user-decryption-option.response.ts | 16 ++ .../user-decryption-options.response.ts | 18 ++- libs/common/src/auth/services/auth.service.ts | 7 +- ...ice-trust-crypto.service.implementation.ts | 51 ++++--- .../device-trust-crypto.service.spec.ts | 109 +++++++++++++- .../models/domain/account-keys.spec.ts | 39 ++++- .../src/platform/models/domain/account.ts | 1 + .../src/platform/services/state.service.ts | 9 +- .../devices-api.service.implementation.ts | 2 +- 22 files changed, 636 insertions(+), 74 deletions(-) create mode 100644 apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts create mode 100644 apps/browser/src/background/service-factories/devices-api-service.factory.ts diff --git a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts index 5612cedb91..45e6d25014 100644 --- a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts @@ -52,6 +52,7 @@ import { PasswordStrengthServiceInitOptions, } from "../../../tools/background/service_factories/password-strength-service.factory"; +import { deviceTrustCryptoServiceFactory } from "./device-trust-crypto-service.factory"; import { keyConnectorServiceFactory, KeyConnectorServiceInitOptions, @@ -101,7 +102,8 @@ export function authServiceFactory( await i18nServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), await passwordStrengthServiceFactory(cache, opts), - await policyServiceFactory(cache, opts) + await policyServiceFactory(cache, opts), + await deviceTrustCryptoServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts new file mode 100644 index 0000000000..efeca261e8 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts @@ -0,0 +1,62 @@ +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; + +import { + DevicesApiServiceInitOptions, + devicesApiServiceFactory, +} from "../../../background/service-factories/devices-api-service.factory"; +import { + AppIdServiceInitOptions, + appIdServiceFactory, +} from "../../../platform/background/service-factories/app-id-service.factory"; +import { + CryptoFunctionServiceInitOptions, + cryptoFunctionServiceFactory, +} from "../../../platform/background/service-factories/crypto-function-service.factory"; +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; +import { + EncryptServiceInitOptions, + encryptServiceFactory, +} from "../../../platform/background/service-factories/encrypt-service.factory"; +import { + CachedServices, + FactoryOptions, + factory, +} from "../../../platform/background/service-factories/factory-options"; +import { + StateServiceInitOptions, + stateServiceFactory, +} from "../../../platform/background/service-factories/state-service.factory"; + +type DeviceTrustCryptoServiceFactoryOptions = FactoryOptions; + +export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactoryOptions & + CryptoFunctionServiceInitOptions & + CryptoServiceInitOptions & + EncryptServiceInitOptions & + StateServiceInitOptions & + AppIdServiceInitOptions & + DevicesApiServiceInitOptions; + +export function deviceTrustCryptoServiceFactory( + cache: { deviceTrustCryptoService?: DeviceTrustCryptoServiceAbstraction } & CachedServices, + opts: DeviceTrustCryptoServiceInitOptions +): Promise { + return factory( + cache, + "deviceTrustCryptoService", + opts, + async () => + new DeviceTrustCryptoService( + await cryptoFunctionServiceFactory(cache, opts), + await cryptoServiceFactory(cache, opts), + await encryptServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await appIdServiceFactory(cache, opts), + await devicesApiServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 051eabdb24..45d793d310 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1,6 +1,7 @@ import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; @@ -16,12 +17,14 @@ import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; @@ -59,6 +62,7 @@ import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/we import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; @@ -195,6 +199,8 @@ export default class MainBackground { cipherContextMenuHandler: CipherContextMenuHandler; configService: ConfigServiceAbstraction; configApiService: ConfigApiServiceAbstraction; + devicesApiService: DevicesApiServiceAbstraction; + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -385,6 +391,17 @@ export default class MainBackground { that.runtimeBackground.processMessage(message, that as any, null); }; })(); + + this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); + this.deviceTrustCryptoService = new DeviceTrustCryptoService( + this.cryptoFunctionService, + this.cryptoService, + this.encryptService, + this.stateService, + this.appIdService, + this.devicesApiService + ); + this.authService = new AuthService( this.cryptoService, this.apiService, @@ -400,7 +417,8 @@ export default class MainBackground { this.i18nService, this.encryptService, this.passwordStrengthService, - this.policyService + this.policyService, + this.deviceTrustCryptoService ); this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( diff --git a/apps/browser/src/background/service-factories/devices-api-service.factory.ts b/apps/browser/src/background/service-factories/devices-api-service.factory.ts new file mode 100644 index 0000000000..0e4affa72b --- /dev/null +++ b/apps/browser/src/background/service-factories/devices-api-service.factory.ts @@ -0,0 +1,28 @@ +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation"; + +import { + ApiServiceInitOptions, + apiServiceFactory, +} from "../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; + +type DevicesApiServiceFactoryOptions = FactoryOptions; + +export type DevicesApiServiceInitOptions = DevicesApiServiceFactoryOptions & ApiServiceInitOptions; + +export function devicesApiServiceFactory( + cache: { devicesApiService?: DevicesApiServiceAbstraction } & CachedServices, + opts: DevicesApiServiceInitOptions +): Promise { + return factory( + cache, + "devicesApiService", + opts, + async () => new DevicesApiServiceImplementation(await apiServiceFactory(cache, opts)) + ); +} diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index c6a7a41bf8..310379391f 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -4,6 +4,7 @@ import * as path from "path"; import * as program from "commander"; import * as jsdom from "jsdom"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -12,7 +13,9 @@ import { OrganizationService } from "@bitwarden/common/admin-console/services/or import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; @@ -34,6 +37,7 @@ import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-m import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation"; import { OrganizationUserServiceImplementation } from "@bitwarden/common/services/organization-user/organization-user.service.implementation"; import { SearchService } from "@bitwarden/common/services/search.service"; import { SettingsService } from "@bitwarden/common/services/settings.service"; @@ -140,6 +144,8 @@ export class Main { organizationApiService: OrganizationApiServiceAbstraction; syncNotifierService: SyncNotifierService; sendApiService: SendApiService; + devicesApiService: DevicesApiServiceAbstraction; + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; constructor() { let p = null; @@ -315,6 +321,16 @@ export class Main { this.stateService ); + this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); + this.deviceTrustCryptoService = new DeviceTrustCryptoService( + this.cryptoFunctionService, + this.cryptoService, + this.encryptService, + this.stateService, + this.appIdService, + this.devicesApiService + ); + this.authService = new AuthService( this.cryptoService, this.apiService, @@ -330,7 +346,8 @@ export class Main { this.i18nService, this.encryptService, this.passwordStrengthService, - this.policyService + this.policyService, + this.deviceTrustCryptoService ); const lockedCallback = async () => diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts index 8f4bee9844..dc15ce4d14 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -102,6 +102,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { const accountDecryptionOptions: AccountDecryptionOptions = await this.stateService.getAccountDecryptionOptions(); + // TODO: verify that this doesn't also pick up key connector users... can key connector users even get here? + // see sso-login.strategy - to determine if a user is new or not it just checks if there is a key on the token response.. + // can we check if they have a user key or master key in crypto service? Would that be sufficient? if ( !accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval && !accountDecryptionOptions?.hasMasterPassword diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 98487f06b0..49f0814370 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -247,6 +247,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; EncryptService, PasswordStrengthServiceAbstraction, PolicyServiceAbstraction, + DeviceTrustCryptoServiceAbstraction, ], }, { diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts index 6b43b224d1..b89af1a351 100644 --- a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -1,4 +1,5 @@ import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { EncString } from "../../platform/models/domain/enc-string"; import { DeviceKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key"; export abstract class DeviceTrustCryptoServiceAbstraction { @@ -11,9 +12,9 @@ export abstract class DeviceTrustCryptoServiceAbstraction { trustDevice: () => Promise; getDeviceKey: () => Promise; - // TODO: update param types when available decryptUserKeyWithDeviceKey: ( - encryptedDevicePrivateKey: any, - encryptedUserKey: any - ) => Promise; + encryptedDevicePrivateKey: EncString, + encryptedUserKey: EncString, + deviceKey?: DeviceKey + ) => Promise; } diff --git a/libs/common/src/auth/login-strategies/login.strategy.spec.ts b/libs/common/src/auth/login-strategies/login.strategy.spec.ts index 2bd1dd0553..f7128b35df 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.spec.ts @@ -9,9 +9,16 @@ import { MessagingService } from "../../platform/abstractions/messaging.service" import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; -import { Account, AccountProfile, AccountTokens } from "../../platform/models/domain/account"; +import { + Account, + AccountDecryptionOptions, + AccountKeys, + AccountProfile, + AccountTokens, +} from "../../platform/models/domain/account"; import { EncString } from "../../platform/models/domain/enc-string"; import { + DeviceKey, MasterKey, SymmetricCryptoKey, UserKey, @@ -34,6 +41,10 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res import { IdentityTokenResponse } from "../models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response"; +import { + IUserDecryptionOptionsServerResponse, + UserDecryptionOptionsResponse, +} from "../models/response/user-decryption-options/user-decryption-options.response"; import { PasswordLogInStrategy } from "./password-login.strategy"; @@ -51,6 +62,13 @@ const kdfIterations = 10000; const userId = Utils.newGuid(); const masterPasswordHash = "MASTER_PASSWORD_HASH"; const name = "NAME"; +const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = { + HasMasterPassword: true, +}; +const userDecryptionOptions = new UserDecryptionOptionsResponse( + defaultUserDecryptionOptionsServerResponse +); +const acctDecryptionOptions = AccountDecryptionOptions.fromResponse(userDecryptionOptions); const decodedToken = { sub: userId, @@ -64,7 +82,8 @@ const twoFactorToken = "TWO_FACTOR_TOKEN"; const twoFactorRemember = true; export function identityTokenResponseFactory( - masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null + masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null, + userDecryptionOptions: IUserDecryptionOptionsServerResponse = null ) { return new IdentityTokenResponse({ ForcePasswordReset: false, @@ -79,9 +98,11 @@ export function identityTokenResponseFactory( scope: "api offline_access", token_type: "Bearer", MasterPasswordPolicy: masterPasswordPolicyResponse, + UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse, }); } +// TODO: add tests for latest changes to base class for TDE describe("LogInStrategy", () => { let cryptoService: MockProxy; let apiService: MockProxy; @@ -149,8 +170,9 @@ describe("LogInStrategy", () => { ) as MasterKey; }); - it("sets the local environment after a successful login", async () => { - apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); + it("sets the local environment after a successful login with master password", async () => { + const idTokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); await passwordLogInStrategy.logIn(credentials); @@ -174,11 +196,36 @@ describe("LogInStrategy", () => { refreshToken: refreshToken, }, }, + keys: new AccountKeys(), + decryptionOptions: acctDecryptionOptions, }) ); expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); }); + it("persists a device key for trusted device encryption when it exists on login", async () => { + // Arrange + const idTokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + const deviceKey = new SymmetricCryptoKey( + new Uint8Array(userKeyBytesLength).buffer as CsprngArray + ) as DeviceKey; + + stateService.getDeviceKey.mockResolvedValue(deviceKey); + + const accountKeys = new AccountKeys(); + accountKeys.deviceKey = deviceKey; + + // Act + await passwordLogInStrategy.logIn(credentials); + + // Assert + expect(stateService.addAccount).toHaveBeenCalledWith( + expect.objectContaining({ keys: accountKeys }) + ); + }); + it("builds AuthResult", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.forcePasswordReset = true; diff --git a/libs/common/src/auth/login-strategies/login.strategy.ts b/libs/common/src/auth/login-strategies/login.strategy.ts index 330df43b2c..51a8015266 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.ts @@ -9,6 +9,7 @@ import { StateService } from "../../platform/abstractions/state.service"; import { Account, AccountDecryptionOptions, + AccountKeys, AccountProfile, AccountTokens, } from "../../platform/models/domain/account"; @@ -104,12 +105,24 @@ export abstract class LogInStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); + // Must persist existing device key if it exists for trusted device decryption to work + // However, we must provide a user id so that the device key can be retrieved + // as the state service won't have an active account at this point in time + // even though the data exists in local storage. + const userId = accountInformation.sub; + + const deviceKey = await this.stateService.getDeviceKey({ userId }); + const accountKeys = new AccountKeys(); + if (deviceKey) { + accountKeys.deviceKey = deviceKey; + } + await this.stateService.addAccount( new Account({ profile: { ...new AccountProfile(), ...{ - userId: accountInformation.sub, + userId, name: accountInformation.name, email: accountInformation.email, hasPremiumPersonally: accountInformation.premium, @@ -126,6 +139,7 @@ export abstract class LogInStrategy { refreshToken: tokenResponse.refreshToken, }, }, + keys: accountKeys, decryptionOptions: AccountDecryptionOptions.fromResponse( tokenResponse.userDecryptionOptions ), diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts index a0775fbdd1..75d31817df 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts @@ -9,16 +9,19 @@ import { PlatformUtilsService } from "../../platform/abstractions/platform-utils import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { + DeviceKey, MasterKey, SymmetricCryptoKey, UserKey, } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { SsoLogInCredentials } from "../models/domain/log-in-credentials"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response"; import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLogInStrategy } from "./sso-login.strategy"; @@ -34,6 +37,7 @@ describe("SsoLogInStrategy", () => { let stateService: MockProxy; let twoFactorService: MockProxy; let keyConnectorService: MockProxy; + let deviceTrustCryptoService: MockProxy; let ssoLogInStrategy: SsoLogInStrategy; let credentials: SsoLogInCredentials; @@ -57,6 +61,7 @@ describe("SsoLogInStrategy", () => { stateService = mock(); twoFactorService = mock(); keyConnectorService = mock(); + deviceTrustCryptoService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -72,7 +77,8 @@ describe("SsoLogInStrategy", () => { logService, stateService, twoFactorService, - keyConnectorService + keyConnectorService, + deviceTrustCryptoService ); credentials = new SsoLogInCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); @@ -110,6 +116,140 @@ describe("SsoLogInStrategy", () => { expect(cryptoService.setPrivateKey).not.toHaveBeenCalled(); }); + it("sets master key encrypted user key for existing SSO users", async () => { + // Arrange + const tokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + }); + + describe("Trusted Device Decryption", () => { + const deviceKeyBytesLength = 64; + const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + const mockDeviceKey: DeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; + + const userKeyBytesLength = 64; + const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength).buffer as CsprngArray; + const mockUserKey: UserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; + + const mockEncDevicePrivateKey = + "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 mockEncUserKey = + "4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw=="; + + const userDecryptionOptsServerResponseWithTdeOption: IUserDecryptionOptionsServerResponse = { + HasMasterPassword: true, + TrustedDeviceOption: { + HasAdminApproval: true, + EncryptedPrivateKey: mockEncDevicePrivateKey, + EncryptedUserKey: mockEncUserKey, + }, + }; + + const mockIdTokenResponseWithModifiedTrustedDeviceOption = (key: string, value: any) => { + const userDecryptionOpts: IUserDecryptionOptionsServerResponse = { + ...userDecryptionOptsServerResponseWithTdeOption, + TrustedDeviceOption: { + ...userDecryptionOptsServerResponseWithTdeOption.TrustedDeviceOption, + [key]: value, + }, + }; + return identityTokenResponseFactory(null, userDecryptionOpts); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("decrypts and sets user key when trusted device decryption option exists with valid device key and enc key data", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); + + const cryptoSvcSetUserKeySpy = jest.spyOn(cryptoService, "setUserKey"); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(deviceTrustCryptoService.getDeviceKey).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).toHaveBeenCalledTimes(1); + expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledTimes(1); + expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey); + }); + + it("does not set the user key when deviceKey is missing", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + // Set deviceKey to be null + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(null); + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + + describe.each([ + { + valueName: "encDevicePrivateKey", + }, + { + valueName: "encUserKey", + }, + ])("given trusted device decryption option has missing encrypted key data", ({ valueName }) => { + it(`does not set the user key when ${valueName} is missing`, async () => { + // Arrange + const idTokenResponse = mockIdTokenResponseWithModifiedTrustedDeviceOption(valueName, null); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + it("does not set user key when decrypted user key is null", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + // Set userKey to be null + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(null); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + }); + describe("Key Connector", () => { let tokenResponse: IdentityTokenResponse; beforeEach(() => { diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.ts index 9d764dc23b..cf389757ed 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.ts @@ -5,6 +5,7 @@ import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -34,7 +35,8 @@ export class SsoLogInStrategy extends LogInStrategy { logService: LogService, stateService: StateService, twoFactorService: TwoFactorService, - private keyConnectorService: KeyConnectorService + private keyConnectorService: KeyConnectorService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction ) { super( cryptoService, @@ -80,37 +82,66 @@ export class SsoLogInStrategy extends LogInStrategy { } protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise { - const newSsoUser = tokenResponse.key == null; - - if (!newSsoUser) { - // TODO: check if TDE feature flag enabled and if token response account decryption options has TDE - // and then if id token response has required device keys - // DevicePublicKey(UserKey) - // UserKey(DevicePublicKey) - // DeviceKey(DevicePrivateKey) - - // Once we have device keys coming back on id token response we can use this code - // const userKey = await this.deviceCryptoService.decryptUserKeyWithDeviceKey( - // encryptedDevicePrivateKey, - // encryptedUserKey - // ); - // await this.cryptoService.setUserKey(userKey); - - // TODO: also admin approval request existence check should go here b/c that can give us a decrypted user key to set - // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) - // so might be worth moving this logic to a common place (base login strategy or a separate service?) - - await this.cryptoService.setMasterKeyEncryptedUserKey(tokenResponse.key); - - if (tokenResponse.keyConnectorUrl != null) { - const masterKey = await this.cryptoService.getMasterKey(); - if (!masterKey) { - throw new Error("Master key not found"); - } - const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); - await this.cryptoService.setUserKey(userKey); - } + // If new user, return b/c we can't set the user key yet + if (tokenResponse.key === null) { + return; } + // Existing user; proceed + + // User now may or may not have a master password + // but set the master key encrypted user key if it exists regardless + await this.cryptoService.setMasterKeyEncryptedUserKey(tokenResponse.key); + + // TODO: also admin approval request existence check should go here b/c that can give us a decrypted user key to set + // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) + // so might be worth moving this logic to a common place (base login strategy or a separate service?) + + const userDecryptionOptions = tokenResponse?.userDecryptionOptions; + + // Note: TDE and key connector are mutually exclusive + if (userDecryptionOptions?.trustedDeviceOption) { + await this.trySetUserKeyWithDeviceKey(tokenResponse); + } else if ( + // TODO: remove tokenResponse.keyConnectorUrl when it's deprecated + tokenResponse.keyConnectorUrl || + userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl + ) { + await this.trySetUserKeyWithMasterKey(); + } + } + + private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise { + const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption; + + const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(); + const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey; + const encUserKey = trustedDeviceOption?.encryptedUserKey; + + if (!deviceKey || !encDevicePrivateKey || !encUserKey) { + return; + } + + const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + encDevicePrivateKey, + encUserKey, + deviceKey + ); + + if (userKey) { + await this.cryptoService.setUserKey(userKey); + } + } + + private async trySetUserKeyWithMasterKey(): Promise { + const masterKey = await this.cryptoService.getMasterKey(); + + if (!masterKey) { + throw new Error("Master key not found"); + } + + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + + await this.cryptoService.setUserKey(userKey); } protected override async setPrivateKey(tokenResponse: IdentityTokenResponse): Promise { diff --git a/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts index 4a7149aea1..6448a9547e 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts @@ -1,9 +1,13 @@ import { BaseResponse } from "../../../../models/response/base.response"; +export interface IKeyConnectorUserDecryptionOptionServerResponse { + KeyConnectorUrl: string; +} + export class KeyConnectorUserDecryptionOptionResponse extends BaseResponse { keyConnectorUrl: string; - constructor(response: any) { + constructor(response: IKeyConnectorUserDecryptionOptionServerResponse) { super(response); this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl"); } diff --git a/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts index d7375c370f..b46f35d849 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts @@ -1,10 +1,26 @@ import { BaseResponse } from "../../../../models/response/base.response"; +import { EncString } from "../../../../platform/models/domain/enc-string"; + +export interface ITrustedDeviceUserDecryptionOptionServerResponse { + HasAdminApproval: boolean; + EncryptedPrivateKey?: string; + EncryptedUserKey?: string; +} export class TrustedDeviceUserDecryptionOptionResponse extends BaseResponse { hasAdminApproval: boolean; + encryptedPrivateKey: EncString; + encryptedUserKey: EncString; constructor(response: any) { super(response); this.hasAdminApproval = this.getResponseProperty("HasAdminApproval"); + + if (response.EncryptedPrivateKey) { + this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + } + if (response.EncryptedUserKey) { + this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + } } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index 196bb28a94..fcf1f49ace 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -1,14 +1,26 @@ import { BaseResponse } from "../../../../models/response/base.response"; -import { KeyConnectorUserDecryptionOptionResponse } from "./key-connector-user-decryption-option.response"; -import { TrustedDeviceUserDecryptionOptionResponse } from "./trusted-device-user-decryption-option.response"; +import { + IKeyConnectorUserDecryptionOptionServerResponse, + KeyConnectorUserDecryptionOptionResponse, +} from "./key-connector-user-decryption-option.response"; +import { + ITrustedDeviceUserDecryptionOptionServerResponse, + TrustedDeviceUserDecryptionOptionResponse, +} from "./trusted-device-user-decryption-option.response"; + +export interface IUserDecryptionOptionsServerResponse { + HasMasterPassword: boolean; + TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse; + KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse; +} export class UserDecryptionOptionsResponse extends BaseResponse { hasMasterPassword: boolean; trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; - constructor(response: any) { + constructor(response: IUserDecryptionOptionsServerResponse) { super(response); this.hasMasterPassword = this.getResponseProperty("HasMasterPassword"); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index b3e2e9f9f4..53bf940ce6 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -19,6 +19,7 @@ import { Utils } from "../../platform/misc/utils"; import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "../../tools/password-strength"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -103,7 +104,8 @@ export class AuthService implements AuthServiceAbstraction { protected i18nService: I18nService, protected encryptService: EncryptService, protected passwordStrengthService: PasswordStrengthServiceAbstraction, - protected policyService: PolicyService + protected policyService: PolicyService, + protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction ) {} async logIn( @@ -149,7 +151,8 @@ export class AuthService implements AuthServiceAbstraction { this.logService, this.stateService, this.twoFactorService, - this.keyConnectorService + this.keyConnectorService, + this.deviceTrustCryptoService ); break; case AuthenticationType.UserApi: diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index df7b8d737f..5aeea7d3be 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -5,6 +5,7 @@ import { CryptoFunctionService } from "../../platform/abstractions/crypto-functi import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey, DeviceKey, @@ -58,7 +59,7 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac deviceKeyEncryptedDevicePrivateKey, ] = await Promise.all([ // Encrypt user key with the DevicePublicKey - this.cryptoService.rsaEncrypt(userKey.encKey, devicePublicKey), + this.cryptoService.rsaEncrypt(userKey.key, devicePublicKey), // Encrypt devicePublicKey with user key this.encryptService.encrypt(devicePublicKey, userKey), @@ -85,7 +86,7 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac return await this.stateService.getDeviceKey(); } - private async setDeviceKey(deviceKey: DeviceKey): Promise { + private async setDeviceKey(deviceKey: DeviceKey | null): Promise { await this.stateService.setDeviceKey(deviceKey); } @@ -97,28 +98,38 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac return deviceKey; } - // TODO: add proper types to parameters once we have them coming down from server - // TODO: add tests for this method async decryptUserKeyWithDeviceKey( - encryptedDevicePrivateKey: any, - encryptedUserKey: any - ): Promise { - // get device key - const existingDeviceKey = await this.getDeviceKey(); + encryptedDevicePrivateKey: EncString, + encryptedUserKey: EncString, + deviceKey?: DeviceKey + ): Promise { + // If device key provided use it, otherwise try to retrieve from storage + deviceKey ||= await this.getDeviceKey(); - if (!existingDeviceKey) { - // TODO: not sure what to do here + if (!deviceKey) { // User doesn't have a device key anymore so device is untrusted - return; + return null; } - // attempt to decrypt encryptedDevicePrivateKey with device key - const devicePrivateKey = await this.encryptService.decryptToBytes( - encryptedDevicePrivateKey, - existingDeviceKey - ); - // Attempt to decrypt encryptedUserDataKey with devicePrivateKey - const userKey = await this.cryptoService.rsaDecrypt(encryptedUserKey, devicePrivateKey); - return new SymmetricCryptoKey(userKey) as UserKey; + try { + // attempt to decrypt encryptedDevicePrivateKey with device key + const devicePrivateKey = await this.encryptService.decryptToBytes( + encryptedDevicePrivateKey, + deviceKey + ); + + // Attempt to decrypt encryptedUserDataKey with devicePrivateKey + const userKey = await this.cryptoService.rsaDecrypt( + encryptedUserKey.encryptedString, + devicePrivateKey + ); + + return new SymmetricCryptoKey(userKey) as UserKey; + } catch (e) { + // If either decryption effort fails, we want to remove the device key + await this.setDeviceKey(null); + + return null; + } } } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts index f738b42084..9a3cf883d3 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -262,6 +262,11 @@ describe("deviceTrustCryptoService", () => { expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1); expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1); + + // RsaEncrypt must be called w/ a user key array buffer of 64 bytes + const userKeyKey: ArrayBuffer = cryptoSvcRsaEncryptSpy.mock.calls[0][0]; + expect(userKeyKey.byteLength).toBe(64); + expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2); expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1); @@ -346,6 +351,108 @@ describe("deviceTrustCryptoService", () => { ); }); - // TOOD Add tests for decryptUserKeyWithDeviceKey when types are available + describe("decryptUserKeyWithDeviceKey", () => { + let mockDeviceKey: DeviceKey; + let mockEncryptedDevicePrivateKey: EncString; + let mockEncryptedUserKey: EncString; + let mockUserKey: UserKey; + + beforeEach(() => { + const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + mockDeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; + + const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength).buffer as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; + + mockEncryptedDevicePrivateKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockEncryptedDevicePrivateKey" + ); + + mockEncryptedUserKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockEncryptedUserKey" + ); + + jest.clearAllMocks(); + }); + + it("returns null when device key isn't provided and isn't in state", async () => { + const getDeviceKeySpy = jest + .spyOn(deviceTrustCryptoService, "getDeviceKey") + .mockResolvedValue(null); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey + ); + + expect(result).toBeNull(); + + expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); + }); + + it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => { + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockResolvedValue(new Uint8Array(userKeyBytesLength).buffer); + const rsaDecryptSpy = jest + .spyOn(cryptoService, "rsaDecrypt") + .mockResolvedValue(new Uint8Array(userKeyBytesLength).buffer); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey + ); + + expect(result).toEqual(mockUserKey); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); + }); + + it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => { + const getDeviceKeySpy = jest + .spyOn(deviceTrustCryptoService, "getDeviceKey") + .mockResolvedValue(mockDeviceKey); + + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockResolvedValue(new Uint8Array(userKeyBytesLength).buffer); + const rsaDecryptSpy = jest + .spyOn(cryptoService, "rsaDecrypt") + .mockResolvedValue(new Uint8Array(userKeyBytesLength).buffer); + + // Call without providing a device key + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey + ); + + expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); + + expect(result).toEqual(mockUserKey); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); + }); + + it("returns null and removes device key when the decryption fails", async () => { + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockRejectedValue(new Error("Decryption error")); + const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey"); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey + ); + + expect(result).toBeNull(); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(setDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(setDeviceKeySpy).toHaveBeenCalledWith(null); + }); + }); }); }); diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts index 88183229f5..e3d7ba315a 100644 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -1,8 +1,9 @@ import { makeStaticByteArray } from "../../../../spec"; +import { CsprngArray } from "../../../types/csprng"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; +import { DeviceKey, SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("AccountKeys", () => { describe("toJSON", () => { @@ -22,6 +23,23 @@ describe("AccountKeys", () => { const json = JSON.stringify(keys); expect(json).toContain('"publicKey":"hello"'); }); + + // As the accountKeys.toJSON doesn't really serialize the device key + // this method just checks the persistence of the deviceKey + it("should persist deviceKey", () => { + // Arrange + const accountKeys = new AccountKeys(); + const deviceKeyBytesLength = 64; + accountKeys.deviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray + ) as DeviceKey; + + // Act + const serializedKeys = accountKeys.toJSON(); + + // Assert + expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey); + }); }); describe("fromJSON", () => { @@ -57,5 +75,24 @@ describe("AccountKeys", () => { } as any); expect(spy).toHaveBeenCalled(); }); + + it("should deserialize deviceKey", () => { + // Arrange + const expectedKeyB64 = + "ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg=="; + + const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); + + // Act + const accountKeys = AccountKeys.fromJSON({ + deviceKey: { + keyB64: expectedKeyB64, + }, + } as any); + + // Assert + expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled(); + expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64); + }); }); }); diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 108419b6bf..c596774e1a 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -149,6 +149,7 @@ export class AccountKeys { return Object.assign(new AccountKeys(), { userKey: SymmetricCryptoKey.fromJSON(obj?.userKey), masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), + deviceKey: SymmetricCryptoKey.fromJSON(obj?.deviceKey), cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 6771263983..9822b6d967 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1302,7 +1302,14 @@ export class StateService< const account = await this.getAccount(options); - return account?.keys?.deviceKey as DeviceKey; + const existingDeviceKey = account?.keys?.deviceKey; + + // Must manually instantiate the SymmetricCryptoKey class from the JSON object + if (existingDeviceKey != null) { + return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey; + } else { + return null; + } } async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { diff --git a/libs/common/src/services/devices/devices-api.service.implementation.ts b/libs/common/src/services/devices/devices-api.service.implementation.ts index a42c786cfa..af8c3b1172 100644 --- a/libs/common/src/services/devices/devices-api.service.implementation.ts +++ b/libs/common/src/services/devices/devices-api.service.implementation.ts @@ -1,9 +1,9 @@ +import { ApiService } from "../../abstractions/api.service"; import { DevicesApiServiceAbstraction } from "../../abstractions/devices/devices-api.service.abstraction"; import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { DeviceType } from "../../enums"; import { ListResponse } from "../../models/response/list.response"; import { Utils } from "../../platform/misc/utils"; -import { ApiService } from "../api.service"; import { TrustedDeviceKeysRequest } from "./requests/trusted-device-keys.request";