diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index cc48a75e4e9..0652070a4da 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -3,9 +3,9 @@ import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -27,7 +27,7 @@ import { flagEnabled } from "../../flags"; export class LoginComponent extends BaseLoginComponent { showPasswordless = false; constructor( - apiService: ApiService, + devicesApiService: DevicesApiServiceAbstraction, appIdService: AppIdService, authService: AuthService, router: Router, @@ -46,7 +46,7 @@ export class LoginComponent extends BaseLoginComponent { loginService: LoginService ) { super( - apiService, + devicesApiService, appIdService, authService, router, diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index efe09efe738..bd7ff1e73b2 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -6,10 +6,10 @@ import { Subject, takeUntil } from "rxjs"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -52,7 +52,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { } constructor( - apiService: ApiService, + devicesApiService: DevicesApiServiceAbstraction, appIdService: AppIdService, authService: AuthService, router: Router, @@ -74,7 +74,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { loginService: LoginService ) { super( - apiService, + devicesApiService, appIdService, authService, router, diff --git a/apps/desktop/src/services/electron-state.service.ts b/apps/desktop/src/services/electron-state.service.ts index b290866dd0b..137302c8019 100644 --- a/apps/desktop/src/services/electron-state.service.ts +++ b/apps/desktop/src/services/electron-state.service.ts @@ -1,6 +1,11 @@ +import { Utils } from "@bitwarden/common/misc/utils"; import { EncString } from "@bitwarden/common/models/domain/enc-string"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { StorageOptions } from "@bitwarden/common/models/domain/storage-options"; +import { + DeviceKey, + SymmetricCryptoKey, +} from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; import { Account } from "../models/account"; @@ -11,6 +16,10 @@ export class ElectronStateService extends BaseStateService implements ElectronStateServiceAbstraction { + private partialKeys = { + deviceKey: "_deviceKey", + }; + async addAccount(account: Account) { // Apply desktop overides to default account values account = new Account(account); @@ -77,4 +86,27 @@ export class ElectronStateService this.reconcileOptions(options, await this.defaultOnDiskOptions()) ); } + + override async getDeviceKey(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); + if (options?.userId == null) { + return; + } + + const b64DeviceKey = await this.secureStorageService.get( + `${options.userId}${this.partialKeys.deviceKey}`, + options + ); + + return new SymmetricCryptoKey(Utils.fromB64ToArray(b64DeviceKey).buffer) as DeviceKey; + } + + override async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); + if (options?.userId == null) { + return; + } + + await this.saveSecureStorageKey(this.partialKeys.deviceKey, value.keyB64, options); + } } diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index 54f9bd36460..fa5a96cc37f 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -5,9 +5,9 @@ import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -41,7 +41,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest private destroy$ = new Subject(); constructor( - apiService: ApiService, + devicesApiService: DevicesApiServiceAbstraction, appIdService: AppIdService, authService: AuthService, router: Router, @@ -63,7 +63,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest loginService: LoginService ) { super( - apiService, + devicesApiService, appIdService, authService, router, diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 0d7c594e695..e882d938946 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -3,9 +3,9 @@ import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { take } from "rxjs/operators"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { AllValidationErrors, @@ -55,7 +55,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } constructor( - protected apiService: ApiService, + protected devicesApiService: DevicesApiServiceAbstraction, protected appIdService: AppIdService, protected authService: AuthService, protected router: Router, @@ -295,7 +295,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit async getLoginWithDevice(email: string) { try { const deviceIdentifier = await this.appIdService.getAppId(); - const res = await this.apiService.getKnownDevice(email, deviceIdentifier); + const res = await this.devicesApiService.getKnownDevice(email, deviceIdentifier); //ensure the application is not self-hosted this.showLoginWithDevice = res && !this.selfHosted; } catch (e) { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7cf75712a00..7ae54c92133 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -10,6 +10,8 @@ import { ConfigApiServiceAbstraction } from "@bitwarden/common/abstractions/conf import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { DeviceCryptoServiceAbstraction } from "@bitwarden/common/abstractions/device-crypto.service.abstraction"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/abstractions/environment.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -90,6 +92,8 @@ import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service import { CryptoService } from "@bitwarden/common/services/crypto.service"; import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/services/cryptography/multithread-encrypt.service.implementation"; +import { DeviceCryptoService } from "@bitwarden/common/services/device-crypto.service.implementation"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation"; import { EnvironmentService } from "@bitwarden/common/services/environment.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -351,6 +355,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; PlatformUtilsServiceAbstraction, LogService, StateServiceAbstraction, + AppIdServiceAbstraction, + DevicesApiServiceAbstraction, ], }, { @@ -656,6 +662,23 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: OrgDomainApiService, deps: [OrgDomainServiceAbstraction, ApiServiceAbstraction], }, + { + provide: DevicesApiServiceAbstraction, + useClass: DevicesApiServiceImplementation, + deps: [ApiServiceAbstraction], + }, + { + provide: DeviceCryptoServiceAbstraction, + useClass: DeviceCryptoService, + deps: [ + CryptoFunctionServiceAbstraction, + CryptoServiceAbstraction, + EncryptService, + StateServiceAbstraction, + AppIdServiceAbstraction, + DevicesApiServiceAbstraction, + ], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 5670a5dc369..2273b290191 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -361,7 +361,6 @@ export abstract class ApiService { putDeviceVerificationSettings: ( request: DeviceVerificationRequest ) => Promise; - getKnownDevice: (email: string, deviceIdentifier: string) => Promise; getEmergencyAccessTrusted: () => Promise>; getEmergencyAccessGranted: () => Promise>; diff --git a/libs/common/src/abstractions/device-crypto.service.abstraction.ts b/libs/common/src/abstractions/device-crypto.service.abstraction.ts new file mode 100644 index 00000000000..23b3be967f5 --- /dev/null +++ b/libs/common/src/abstractions/device-crypto.service.abstraction.ts @@ -0,0 +1,8 @@ +import { DeviceKey } from "../models/domain/symmetric-crypto-key"; + +import { DeviceResponse } from "./devices/responses/device.response"; + +export abstract class DeviceCryptoServiceAbstraction { + trustDevice: () => Promise; + getDeviceKey: () => Promise; +} diff --git a/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts b/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts new file mode 100644 index 00000000000..345b728977e --- /dev/null +++ b/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts @@ -0,0 +1,14 @@ +import { DeviceResponse } from "./responses/device.response"; + +export abstract class DevicesApiServiceAbstraction { + getKnownDevice: (email: string, deviceIdentifier: string) => Promise; + + getDeviceByIdentifier: (deviceIdentifier: string) => Promise; + + updateTrustedDeviceKeys: ( + deviceIdentifier: string, + devicePublicKeyEncryptedUserSymKey: string, + userSymKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ) => Promise; +} diff --git a/libs/common/src/auth/models/response/device.response.ts b/libs/common/src/abstractions/devices/responses/device.response.ts similarity index 65% rename from libs/common/src/auth/models/response/device.response.ts rename to libs/common/src/abstractions/devices/responses/device.response.ts index 2770499e81e..331df2e16cd 100644 --- a/libs/common/src/auth/models/response/device.response.ts +++ b/libs/common/src/abstractions/devices/responses/device.response.ts @@ -7,6 +7,9 @@ export class DeviceResponse extends BaseResponse { identifier: string; type: DeviceType; creationDate: string; + encryptedUserKey: string; + encryptedPublicKey: string; + encryptedPrivateKey: string; constructor(response: any) { super(response); @@ -15,5 +18,8 @@ export class DeviceResponse extends BaseResponse { this.identifier = this.getResponseProperty("Identifier"); this.type = this.getResponseProperty("Type"); this.creationDate = this.getResponseProperty("CreationDate"); + this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey"); + this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey"); + this.encryptedPrivateKey = this.getResponseProperty("EncryptedPrivateKey"); } } diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/abstractions/state.service.ts index ac5bb705969..04cfb609fed 100644 --- a/libs/common/src/abstractions/state.service.ts +++ b/libs/common/src/abstractions/state.service.ts @@ -17,7 +17,7 @@ import { ServerConfigData } from "../models/data/server-config.data"; import { Account, AccountSettingsSettings } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { WindowState } from "../models/domain/window-state"; import { GeneratedPasswordHistory } from "../tools/generator/password"; import { SendData } from "../tools/send/models/data/send.data"; @@ -163,6 +163,8 @@ export abstract class StateService { setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; + getDeviceKey: (options?: StorageOptions) => Promise; + setDeviceKey: (value: DeviceKey, options?: StorageOptions) => Promise; getEmail: (options?: StorageOptions) => Promise; setEmail: (value: string, options?: StorageOptions) => Promise; getEmailVerified: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index a7988d41d79..db98a17b42c 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -23,7 +23,7 @@ import { EventData } from "../data/event.data"; import { ServerConfigData } from "../data/server-config.data"; import { EncString } from "./enc-string"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; +import { DeviceKey, SymmetricCryptoKey } from "./symmetric-crypto-key"; export class EncryptionPair { encrypted?: TEncrypted; @@ -107,6 +107,7 @@ export class AccountKeys { string, SymmetricCryptoKey >(); + deviceKey?: DeviceKey; organizationKeys?: EncryptionPair< { [orgId: string]: EncryptedOrganizationKeyData }, Record diff --git a/libs/common/src/models/domain/symmetric-crypto-key.ts b/libs/common/src/models/domain/symmetric-crypto-key.ts index 3bd329dd21b..8c9920d1319 100644 --- a/libs/common/src/models/domain/symmetric-crypto-key.ts +++ b/libs/common/src/models/domain/symmetric-crypto-key.ts @@ -1,4 +1,4 @@ -import { Jsonify } from "type-fest"; +import { Jsonify, Opaque } from "type-fest"; import { EncryptionType } from "../../enums"; import { Utils } from "../../misc/utils"; @@ -75,3 +75,6 @@ export class SymmetricCryptoKey { return SymmetricCryptoKey.fromString(obj?.keyB64); } } + +// Setup all separate key types as opaque types +export type DeviceKey = Opaque; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index e02bfb743fe..59e3755a04f 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1110,14 +1110,6 @@ export class ApiService implements ApiServiceAbstraction { return new DeviceVerificationResponse(r); } - async getKnownDevice(email: string, deviceIdentifier: string): Promise { - const r = await this.send("GET", "/devices/knowndevice", null, false, true, null, (headers) => { - headers.set("X-Device-Identifier", deviceIdentifier); - headers.set("X-Request-Email", Utils.fromUtf8ToUrlB64(email)); - }); - return r as boolean; - } - // Emergency Access APIs async getEmergencyAccessTrusted(): Promise> { diff --git a/libs/common/src/services/device-crypto.service.implementation.ts b/libs/common/src/services/device-crypto.service.implementation.ts new file mode 100644 index 00000000000..ba50e300b42 --- /dev/null +++ b/libs/common/src/services/device-crypto.service.implementation.ts @@ -0,0 +1,85 @@ +import { AppIdService } from "../abstractions/appId.service"; +import { CryptoService } from "../abstractions/crypto.service"; +import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; +import { DeviceCryptoServiceAbstraction } from "../abstractions/device-crypto.service.abstraction"; +import { DevicesApiServiceAbstraction } from "../abstractions/devices/devices-api.service.abstraction"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { StateService } from "../abstractions/state.service"; +import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../types/csprng"; + +export class DeviceCryptoService implements DeviceCryptoServiceAbstraction { + constructor( + protected cryptoFunctionService: CryptoFunctionService, + protected cryptoService: CryptoService, + protected encryptService: EncryptService, + protected stateService: StateService, + protected appIdService: AppIdService, + protected devicesApiService: DevicesApiServiceAbstraction + ) {} + + async trustDevice(): Promise { + // Attempt to get user symmetric key + const userSymKey: SymmetricCryptoKey = await this.cryptoService.getEncKey(); + + // If user symmetric key is not found, throw error + if (!userSymKey) { + throw new Error("User symmetric key not found"); + } + + // Generate deviceKey + const deviceKey = await this.makeDeviceKey(); + + // Generate asymmetric RSA key pair: devicePrivateKey, devicePublicKey + const [devicePublicKey, devicePrivateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair( + 2048 + ); + + const [ + devicePublicKeyEncryptedUserSymKey, + userSymKeyEncryptedDevicePublicKey, + deviceKeyEncryptedDevicePrivateKey, + ] = await Promise.all([ + // Encrypt user symmetric key with the DevicePublicKey + this.cryptoService.rsaEncrypt(userSymKey.encKey, devicePublicKey), + + // Encrypt devicePublicKey with user symmetric key + this.encryptService.encrypt(devicePublicKey, userSymKey), + + // Encrypt devicePrivateKey with deviceKey + this.encryptService.encrypt(devicePrivateKey, deviceKey), + ]); + + // Send encrypted keys to server + const deviceIdentifier = await this.appIdService.getAppId(); + return this.devicesApiService.updateTrustedDeviceKeys( + deviceIdentifier, + devicePublicKeyEncryptedUserSymKey.encryptedString, + userSymKeyEncryptedDevicePublicKey.encryptedString, + deviceKeyEncryptedDevicePrivateKey.encryptedString + ); + } + + async getDeviceKey(): Promise { + // Check if device key is already stored + const existingDeviceKey = await this.stateService.getDeviceKey(); + + if (existingDeviceKey != null) { + return existingDeviceKey; + } else { + return this.makeDeviceKey(); + } + } + + private async makeDeviceKey(): Promise { + // Create 512-bit device key + const randomBytes: CsprngArray = await this.cryptoFunctionService.randomBytes(64); + const deviceKey = new SymmetricCryptoKey(randomBytes) as DeviceKey; + + // Save device key in secure storage + await this.stateService.setDeviceKey(deviceKey); + + return deviceKey; + } +} diff --git a/libs/common/src/services/device-crypto.service.spec.ts b/libs/common/src/services/device-crypto.service.spec.ts new file mode 100644 index 00000000000..7e14961cc2c --- /dev/null +++ b/libs/common/src/services/device-crypto.service.spec.ts @@ -0,0 +1,317 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { AppIdService } from "../abstractions/appId.service"; +import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; +import { DevicesApiServiceAbstraction } from "../abstractions/devices/devices-api.service.abstraction"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { StateService } from "../abstractions/state.service"; +import { EncryptionType } from "../enums/encryption-type.enum"; +import { EncString } from "../models/domain/enc-string"; +import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { CryptoService } from "../services/crypto.service"; +import { CsprngArray } from "../types/csprng"; + +import { DeviceCryptoService } from "./device-crypto.service.implementation"; + +describe("deviceCryptoService", () => { + let deviceCryptoService: DeviceCryptoService; + + const cryptoFunctionService = mock(); + const cryptoService = mock(); + const encryptService = mock(); + const stateService = mock(); + const appIdService = mock(); + const devicesApiService = mock(); + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(encryptService); + mockReset(stateService); + mockReset(appIdService); + mockReset(devicesApiService); + + deviceCryptoService = new DeviceCryptoService( + cryptoFunctionService, + cryptoService, + encryptService, + stateService, + appIdService, + devicesApiService + ); + }); + + it("instantiates", () => { + expect(deviceCryptoService).not.toBeFalsy(); + }); + + describe("Trusted Device Encryption", () => { + const deviceKeyBytesLength = 64; + const userSymKeyBytesLength = 64; + + describe("getDeviceKey", () => { + let mockRandomBytes: CsprngArray; + let mockDeviceKey: SymmetricCryptoKey; + let existingDeviceKey: DeviceKey; + let stateSvcGetDeviceKeySpy: jest.SpyInstance; + let makeDeviceKeySpy: jest.SpyInstance; + + beforeEach(() => { + mockRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + mockDeviceKey = new SymmetricCryptoKey(mockRandomBytes); + existingDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray + ) as DeviceKey; + + stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey"); + makeDeviceKeySpy = jest.spyOn(deviceCryptoService as any, "makeDeviceKey"); + }); + + it("gets a device key when there is not an existing device key", async () => { + stateSvcGetDeviceKeySpy.mockResolvedValue(null); + makeDeviceKeySpy.mockResolvedValue(mockDeviceKey); + + const deviceKey = await deviceCryptoService.getDeviceKey(); + + expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(mockDeviceKey); + }); + + it("returns the existing device key without creating a new one when there is an existing device key", async () => { + stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey); + + const deviceKey = await deviceCryptoService.getDeviceKey(); + + expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(makeDeviceKeySpy).not.toHaveBeenCalled(); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + }); + }); + + describe("makeDeviceKey", () => { + it("creates a new non-null 64 byte device key, securely stores it, and returns it", async () => { + const mockRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + + const cryptoFuncSvcRandomBytesSpy = jest + .spyOn(cryptoFunctionService, "randomBytes") + .mockResolvedValue(mockRandomBytes); + + const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey"); + + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + const deviceKey = await (deviceCryptoService as any).makeDeviceKey(); + + expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledTimes(1); + expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledWith(deviceKeyBytesLength); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + + expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey); + }); + }); + + describe("trustDevice", () => { + let mockDeviceKeyRandomBytes: CsprngArray; + let mockDeviceKey: DeviceKey; + + let mockUserSymKeyRandomBytes: CsprngArray; + let mockUserSymKey: SymmetricCryptoKey; + + const deviceRsaKeyLength = 2048; + let mockDeviceRsaKeyPair: [ArrayBuffer, ArrayBuffer]; + let mockDevicePrivateKey: ArrayBuffer; + let mockDevicePublicKey: ArrayBuffer; + let mockDevicePublicKeyEncryptedUserSymKey: EncString; + let mockUserSymKeyEncryptedDevicePublicKey: EncString; + let mockDeviceKeyEncryptedDevicePrivateKey: EncString; + + const mockDeviceResponse: DeviceResponse = new DeviceResponse({ + Id: "mockId", + Name: "mockName", + Identifier: "mockIdentifier", + Type: "mockType", + CreationDate: "mockCreationDate", + }); + + const mockDeviceId = "mockDeviceId"; + + let makeDeviceKeySpy: jest.SpyInstance; + let rsaGenerateKeyPairSpy: jest.SpyInstance; + let cryptoSvcGetEncKeySpy: jest.SpyInstance; + let cryptoSvcRsaEncryptSpy: jest.SpyInstance; + let encryptServiceEncryptSpy: jest.SpyInstance; + let appIdServiceGetAppIdSpy: jest.SpyInstance; + let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance; + + beforeEach(() => { + // Setup all spies and default return values for the happy path + + mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + mockDeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; + + mockUserSymKeyRandomBytes = new Uint8Array(userSymKeyBytesLength).buffer as CsprngArray; + mockUserSymKey = new SymmetricCryptoKey(mockUserSymKeyRandomBytes); + + mockDeviceRsaKeyPair = [ + new ArrayBuffer(deviceRsaKeyLength), + new ArrayBuffer(deviceRsaKeyLength), + ]; + + mockDevicePublicKey = mockDeviceRsaKeyPair[0]; + mockDevicePrivateKey = mockDeviceRsaKeyPair[1]; + + mockDevicePublicKeyEncryptedUserSymKey = new EncString( + EncryptionType.Rsa2048_OaepSha1_B64, + "mockDevicePublicKeyEncryptedUserSymKey" + ); + + mockUserSymKeyEncryptedDevicePublicKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockUserSymKeyEncryptedDevicePublicKey" + ); + + mockDeviceKeyEncryptedDevicePrivateKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockDeviceKeyEncryptedDevicePrivateKey" + ); + + // TypeScript will allow calling private methods if the object is of type 'any' + makeDeviceKeySpy = jest + .spyOn(deviceCryptoService as any, "makeDeviceKey") + .mockResolvedValue(mockDeviceKey); + + rsaGenerateKeyPairSpy = jest + .spyOn(cryptoFunctionService, "rsaGenerateKeyPair") + .mockResolvedValue(mockDeviceRsaKeyPair); + + cryptoSvcGetEncKeySpy = jest + .spyOn(cryptoService, "getEncKey") + .mockResolvedValue(mockUserSymKey); + + cryptoSvcRsaEncryptSpy = jest + .spyOn(cryptoService, "rsaEncrypt") + .mockResolvedValue(mockDevicePublicKeyEncryptedUserSymKey); + + encryptServiceEncryptSpy = jest + .spyOn(encryptService, "encrypt") + .mockImplementation((plainValue, key) => { + if (plainValue === mockDevicePublicKey && key === mockUserSymKey) { + return Promise.resolve(mockUserSymKeyEncryptedDevicePublicKey); + } + if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) { + return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey); + } + }); + + appIdServiceGetAppIdSpy = jest + .spyOn(appIdService, "getAppId") + .mockResolvedValue(mockDeviceId); + + devicesApiServiceUpdateTrustedDeviceKeysSpy = jest + .spyOn(devicesApiService, "updateTrustedDeviceKeys") + .mockResolvedValue(mockDeviceResponse); + }); + + it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => { + const response = await deviceCryptoService.trustDevice(); + + expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); + expect(cryptoSvcGetEncKeySpy).toHaveBeenCalledTimes(1); + + expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1); + expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2); + + expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1); + expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1); + expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledWith( + mockDeviceId, + mockDevicePublicKeyEncryptedUserSymKey.encryptedString, + mockUserSymKeyEncryptedDevicePublicKey.encryptedString, + mockDeviceKeyEncryptedDevicePrivateKey.encryptedString + ); + + expect(response).toBeInstanceOf(DeviceResponse); + expect(response).toEqual(mockDeviceResponse); + }); + + it("throws specific error if user symmetric key is not found", async () => { + // setup the spy to return null + cryptoSvcGetEncKeySpy.mockResolvedValue(null); + // check if the expected error is thrown + await expect(deviceCryptoService.trustDevice()).rejects.toThrow( + "User symmetric key not found" + ); + + // reset the spy + cryptoSvcGetEncKeySpy.mockReset(); + + // setup the spy to return undefined + cryptoSvcGetEncKeySpy.mockResolvedValue(undefined); + // check if the expected error is thrown + await expect(deviceCryptoService.trustDevice()).rejects.toThrow( + "User symmetric key not found" + ); + }); + + const methodsToTestForErrorsOrInvalidReturns = [ + { + method: "makeDeviceKey", + spy: () => makeDeviceKeySpy, + errorText: "makeDeviceKey error", + }, + { + method: "rsaGenerateKeyPair", + spy: () => rsaGenerateKeyPairSpy, + errorText: "rsaGenerateKeyPair error", + }, + { + method: "getEncKey", + spy: () => cryptoSvcGetEncKeySpy, + errorText: "getEncKey error", + }, + { + method: "rsaEncrypt", + spy: () => cryptoSvcRsaEncryptSpy, + errorText: "rsaEncrypt error", + }, + { + method: "encryptService.encrypt", + spy: () => encryptServiceEncryptSpy, + errorText: "encryptService.encrypt error", + }, + ]; + + describe.each(methodsToTestForErrorsOrInvalidReturns)( + "trustDevice error handling and invalid return testing", + ({ method, spy, errorText }) => { + // ensures that error propagation works correctly + it(`throws an error if ${method} fails`, async () => { + const methodSpy = spy(); + methodSpy.mockRejectedValue(new Error(errorText)); + await expect(deviceCryptoService.trustDevice()).rejects.toThrow(errorText); + }); + + test.each([null, undefined])( + `throws an error if ${method} returns %s`, + async (invalidValue) => { + const methodSpy = spy(); + methodSpy.mockResolvedValue(invalidValue); + await expect(deviceCryptoService.trustDevice()).rejects.toThrow(); + } + ); + } + ); + }); + }); +}); diff --git a/libs/common/src/services/devices/devices-api.service.implementation.ts b/libs/common/src/services/devices/devices-api.service.implementation.ts new file mode 100644 index 00000000000..aa0d0f0c297 --- /dev/null +++ b/libs/common/src/services/devices/devices-api.service.implementation.ts @@ -0,0 +1,64 @@ +import { DevicesApiServiceAbstraction } from "../../abstractions/devices/devices-api.service.abstraction"; +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { Utils } from "../../misc/utils"; +import { ApiService } from "../api.service"; + +import { TrustedDeviceKeysRequest } from "./requests/trusted-device-keys.request"; + +export class DevicesApiServiceImplementation implements DevicesApiServiceAbstraction { + constructor(private apiService: ApiService) {} + + async getKnownDevice(email: string, deviceIdentifier: string): Promise { + const r = await this.apiService.send( + "GET", + "/devices/knowndevice", + null, + false, + true, + null, + (headers) => { + headers.set("X-Device-Identifier", deviceIdentifier); + headers.set("X-Request-Email", Utils.fromUtf8ToUrlB64(email)); + } + ); + return r as boolean; + } + + /** + * Get device by identifier + * @param deviceIdentifier - client generated id (not device id in DB) + */ + async getDeviceByIdentifier(deviceIdentifier: string): Promise { + const r = await this.apiService.send( + "GET", + `/devices/identifier/${deviceIdentifier}`, + null, + true, + true + ); + return new DeviceResponse(r); + } + + async updateTrustedDeviceKeys( + deviceIdentifier: string, + devicePublicKeyEncryptedUserSymKey: string, + userSymKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ): Promise { + const request = new TrustedDeviceKeysRequest( + devicePublicKeyEncryptedUserSymKey, + userSymKeyEncryptedDevicePublicKey, + deviceKeyEncryptedDevicePrivateKey + ); + + const result = await this.apiService.send( + "PUT", + `/devices/${deviceIdentifier}/keys`, + request, + true, + true + ); + + return new DeviceResponse(result); + } +} diff --git a/libs/common/src/services/devices/requests/trusted-device-keys.request.ts b/libs/common/src/services/devices/requests/trusted-device-keys.request.ts new file mode 100644 index 00000000000..da89de975b0 --- /dev/null +++ b/libs/common/src/services/devices/requests/trusted-device-keys.request.ts @@ -0,0 +1,7 @@ +export class TrustedDeviceKeysRequest { + constructor( + public encryptedUserKey: string, + public encryptedPublicKey: string, + public encryptedPrivateKey: string + ) {} +} diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index 4a1592e733c..dc32c688e30 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -35,7 +35,7 @@ import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; import { State } from "../models/domain/state"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { WindowState } from "../models/domain/window-state"; import { GeneratedPasswordHistory } from "../tools/generator/password"; import { SendData } from "../tools/send/models/data/send.data"; @@ -1054,6 +1054,32 @@ export class StateService< : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); } + async getDeviceKey(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return null; + } + + const account = await this.getAccount(options); + + return account?.keys?.deviceKey as DeviceKey; + } + + async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return; + } + + const account = await this.getAccount(options); + + account.keys.deviceKey = value; + + await this.saveAccount(account, options); + } + async getEmail(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -2751,7 +2777,10 @@ export class StateService< // settings persist even on reset, and are not effected by this method protected resetAccount(account: TAccount) { - const persistentAccountInformation = { settings: account.settings }; + const persistentAccountInformation = { + settings: account.settings, + keys: { deviceKey: account.keys.deviceKey }, + }; return Object.assign(this.createAccount(), persistentAccountInformation); } @@ -2830,7 +2859,7 @@ export class StateService< return this.reconcileOptions(options, defaultOptions); } - private async saveSecureStorageKey( + protected async saveSecureStorageKey( key: string, value: T, options?: StorageOptions diff --git a/libs/common/src/types/csprng.d.ts b/libs/common/src/types/csprng.d.ts index b62f8b37a68..ec0a31a9f78 100644 --- a/libs/common/src/types/csprng.d.ts +++ b/libs/common/src/types/csprng.d.ts @@ -1,5 +1,9 @@ import { Opaque } from "type-fest"; +// You would typically use these types when you want to create a type that +// represents an array or string value generated from a +// cryptographic secure pseudorandom number generator (CSPRNG) + type CsprngArray = Opaque; type CsprngString = Opaque;