Feature/[PM-1378] - Trusted Device Encryption - Establish trust logic for all clients (#5339)

* PM1378 - (1) Create state service methods for securely storing a device symmetric key while following existing pattern of DuckDuckGoKey generation (2) Create makeDeviceKey method on crypto service which leverages the new state service methods for storing the device key.

* PM-1378 - Document CSPRNG types w/ comments explaining what they are and when they should be used.

* PM-1378 - TODO to add tests for makeDeviceKey method

* PM-1378 - Create Devices API service for creating and updating device encrypted master keys + move models according to latest code standards ( I think)

* PM-1378 - TODO clean up - DeviceResponse properly moved next to device api service abstraction per ADR 0013

* PM-1378 - CryptoService makeDeviceKey test written

* PM-1378 - Tweak crypto service makeDeviceKey test to leverage a describe for the function to better group related code.

* PM-1378 - Move known devices call out of API service and into new devices-api.service and update all references. All clients building.

* PM-1378 - Comment clean up

* PM-1378 - Refactor out master key naming as that is a reserved specific key generated from the MP key derivation process + use same property on request object as back end.

* PM-1378 - Missed a use of master key

* PM-1378 - More abstraction updates to remove master key.

* PM-1378 - Convert crypto service makeDeviceKey into getDeviceKey method to consolidate service logic based on PR feedback

* PM-1378- Updating makeDeviceKey --> getDeviceKey tests to match updated code

* PM-1378 - Current work on updating establish trusted device logic in light of new encryption mechanisms (introduction of a device asymmetric key pair in order to allow for key rotation while maintaining trusted devices)

* PM-1378 - (1) CryptoService.TrustDevice() naming refactors (2) Lots of test additions and tweaks for trustDevice()

* PM-1378 - Updated TrustedDeviceKeysRequest names to be consistent across the client side board.

* PM-1378 - Move trusted device crypto service methods out of crypto service into new DeviceCryptoService for better single responsibility design

* PM-1378 - (1) Add getDeviceByIdentifier endpoint to devices api as will need it later (2) Update TrustedDeviceKeysRequest and DeviceResponse models to match latest server side generic encrypted key names

* PM-1378 - PR feedback fix - use JSDOC comments and move from abstraction to implementation

* PM-1378 - Per PR feedback, makeDeviceKey should be private - updated tests with workaround.

* PM-1378- Per PR feedback, refactored deviceKey to use partialKey dict so we can associate userId with specific device keys.

* PM-1378 - Replace deviceId with deviceIdentifier per PR feedback

* PM-1378 - Remove unnecessary createTrustedDeviceKey methods

* PM-1378 - Update device crypto service to leverage updateTrustedDeviceKeys + update tests

* PM-1378 - Update trustDevice logic - (1) Use getEncKey to get user symmetric key as it's the correct method and (2) Attempt to retrieve the userSymKey earlier on and short circuit if it is not found.

* PM-1378 - Replace deviceId with deviceIdentifier because they are not the same thing

* PM-1378 - Per PR feedback, (1) on web/browser extension, store device key in local storage under account.keys existing structure (2) on desktop, store deviceKey in secure storage. (3) Exempt account.keys.deviceKey from being cleared on account reset

* PM-1378 - Desktop testing revealed that I forgot to add userId existence and options reconciliation checks back

* PM-1378 - Per discussion with Jake, create DeviceKey custom type which is really just an opaque<SymmetricCryptoKey> so we can more easily differentiate between key types.

* PM-1378 - Update symmetric-crypto-key.ts opaque DeviceKey to properly setup Opaque type.

* PM-1378 - Fix wrong return type for getDeviceKey on DeviceCryptoServiceAbstraction per PR feedback
This commit is contained in:
Jared Snider 2023-05-25 14:17:19 -04:00 committed by GitHub
parent b9d3b0aff7
commit 0fcfe883b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 613 additions and 27 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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<GlobalState, Account>
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<DeviceKey | null> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
const b64DeviceKey = await this.secureStorageService.get<string>(
`${options.userId}${this.partialKeys.deviceKey}`,
options
);
return new SymmetricCryptoKey(Utils.fromB64ToArray(b64DeviceKey).buffer) as DeviceKey;
}
override async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(this.partialKeys.deviceKey, value.keyB64, options);
}
}

View File

@ -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<void>();
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,

View File

@ -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) {

View File

@ -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 {}

View File

@ -361,7 +361,6 @@ export abstract class ApiService {
putDeviceVerificationSettings: (
request: DeviceVerificationRequest
) => Promise<DeviceVerificationResponse>;
getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>;
getEmergencyAccessTrusted: () => Promise<ListResponse<EmergencyAccessGranteeDetailsResponse>>;
getEmergencyAccessGranted: () => Promise<ListResponse<EmergencyAccessGrantorDetailsResponse>>;

View File

@ -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<DeviceResponse>;
getDeviceKey: () => Promise<DeviceKey>;
}

View File

@ -0,0 +1,14 @@
import { DeviceResponse } from "./responses/device.response";
export abstract class DevicesApiServiceAbstraction {
getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>;
getDeviceByIdentifier: (deviceIdentifier: string) => Promise<DeviceResponse>;
updateTrustedDeviceKeys: (
deviceIdentifier: string,
devicePublicKeyEncryptedUserSymKey: string,
userSymKeyEncryptedDevicePublicKey: string,
deviceKeyEncryptedDevicePrivateKey: string
) => Promise<DeviceResponse>;
}

View File

@ -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");
}
}

View File

@ -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<T extends Account = Account> {
setDontShowIdentitiesCurrentTab: (value: boolean, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getDeviceKey: (options?: StorageOptions) => Promise<DeviceKey | null>;
setDeviceKey: (value: DeviceKey, options?: StorageOptions) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;

View File

@ -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<TEncrypted, TDecrypted> {
encrypted?: TEncrypted;
@ -107,6 +107,7 @@ export class AccountKeys {
string,
SymmetricCryptoKey
>();
deviceKey?: DeviceKey;
organizationKeys?: EncryptionPair<
{ [orgId: string]: EncryptedOrganizationKeyData },
Record<string, SymmetricCryptoKey>

View File

@ -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<SymmetricCryptoKey, "DeviceKey">;

View File

@ -1110,14 +1110,6 @@ export class ApiService implements ApiServiceAbstraction {
return new DeviceVerificationResponse(r);
}
async getKnownDevice(email: string, deviceIdentifier: string): Promise<boolean> {
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<ListResponse<EmergencyAccessGranteeDetailsResponse>> {

View File

@ -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<DeviceResponse> {
// 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<DeviceKey> {
// 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<DeviceKey> {
// 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;
}
}

View File

@ -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<CryptoFunctionService>();
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
const stateService = mock<StateService>();
const appIdService = mock<AppIdService>();
const devicesApiService = mock<DevicesApiServiceAbstraction>();
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();
}
);
}
);
});
});
});

View File

@ -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<boolean> {
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<DeviceResponse> {
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<DeviceResponse> {
const request = new TrustedDeviceKeysRequest(
devicePublicKeyEncryptedUserSymKey,
userSymKeyEncryptedDevicePublicKey,
deviceKeyEncryptedDevicePrivateKey
);
const result = await this.apiService.send(
"PUT",
`/devices/${deviceIdentifier}/keys`,
request,
true,
true
);
return new DeviceResponse(result);
}
}

View File

@ -0,0 +1,7 @@
export class TrustedDeviceKeysRequest {
constructor(
public encryptedUserKey: string,
public encryptedPublicKey: string,
public encryptedPrivateKey: string
) {}
}

View File

@ -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<DeviceKey | null> {
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<void> {
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<string> {
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<T extends JsonValue>(
protected async saveSecureStorageKey<T extends JsonValue>(
key: string,
value: T,
options?: StorageOptions

View File

@ -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<ArrayBuffer, "CSPRNG">;
type CsprngString = Opaque<string, "CSPRNG">;