1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 01:03:35 +00:00

Auth/PM-5268 - DeviceTrustCryptoService state provider migration (#7882)

* PM-5268 - Add DEVICE_TRUST_DISK to state definitions

* PM-5268 - DeviceTrustCryptoService - Get most of state provider refactor done - WIP - commented out stuff for now.

* PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - WIP - got first draft of migrator in place and working on tests. Rollback tests are failing for some reason TBD.

* PM-5268 - more WIP on device trust crypto service migrator tests

* PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - Refactor based on call with platform

* PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - tests passing

* PM-5268 - Update DeviceTrustCryptoService to convert over to state providers + update all service instantiations / dependencies to ensure state provider is passed in or injected.

* PM-5268 - Register new migration

* PM-5268 - Temporarily remove device trust crypto service from migrator to ease merge conflicts as there are 6 more migrators before I can apply mine in main.

* PM-5268 - Update migration numbers of DeviceTrustCryptoServiceStateProviderMigrator based on latest migrations from main.

* PM-5268 - (1) Export new KeyDefinitions from DeviceTrustCryptoService for use in test suite (2) Update DeviceTrustCryptoService test file to use state provider.

* PM-5268 - Fix DeviceTrustCryptoServiceStateProviderMigrator tests to use proper versions

* PM-5268 - Actually fix all instances of DeviceTrustCryptoServiceStateProviderMigrator test failures

* PM-5268 - Clean up state service, account, and login strategy of all migrated references

* PM-5268 - Account - finish cleaning up device key

* PM-5268 - StateService - clean up last reference to device key

* PM-5268 - Remove even more device key refs. *facepalm*

* PM-5268 - Finish resolving merge conflicts by incrementing migration version from 22 to 23

* PM-5268 - bump migration versions

* PM-5268 - DeviceTrustCryptoService - Implement secure storage functionality for getDeviceKey and setDeviceKey (to achieve feature parity with the ElectronStateService implementation prior to the state provider migration). Tests to follow shortly.

* PM-5268 - DeviceTrustCryptoService tests - getDeviceKey now tested with all new secure storage scenarios. SetDeviceKey tests to follow.

* PM-5268 - DeviceTrustCryptoService tests - test all setDeviceKey scenarios with state provider & secure storage

* PM-5268 - Update DeviceTrustCryptoService deps to actually use secure storage svc on platforms that support it.

* PM-5268 - Bump migration version due to merge conflicts.

* PM-5268 - Bump migration version

* PM-5268 - tweak jsdocs to be single line per PR feedback

* PM-5268 - DeviceTrustCryptoSvc - improve debuggability.

* PM-5268 - Remove state service as a dependency on the device trust crypto service (woo!)

* PM-5268 - Update migration test json to correctly reflect reality.

* PM-5268 - DeviceTrustCryptoSvc - getDeviceKey - add throw error for active user id missing.

* PM-5268 - Fix tests

* PM-5268 - WIP start on adding user id to every method on device trust crypto service.

* PM-5268 - Update lock comp dependencies across clients

* PM-5268 - Update login via auth request deps across clients to add acct service.

* PM-5268 - UserKeyRotationSvc - add acct service to get active acct id for call to rotateDevicesTrust and then update tests.

* PM-5268 - WIP on trying to fix device trust crypto svc tests.

* PM-5268 - More WIP device trust crypto svc tests passing

* PM-5268 - Device Trust crypto service - get all tests passing

* PM-5268 - DeviceTrustCryptoService.getDeviceKey - fix secure storage b64 to symmetric crypto key conversion

* PM-5268 - Add more tests and update test names

* PM-5268 - rename state to indicate it was disk local

* PM-5268 - DeviceTrustCryptoService - save symmetric key in JSON format

* PM-5268 - Fix lock comp tests by adding acct service dep

* PM-5268 - Update set device key tests to pass

* PM-5268 - Bump migration versions again

* PM-5268 - Fix user key rotation svc tests

* PM-5268 - Update web jest config to allow use of common spec in user-key-rotation-svc tests

* PM-5268 - Bump migration version

* PM-5268 - Per PR feedback, save off user id

* PM-5268 - bump migration version

* PM-5268 - Per PR feedback, remove unnecessary await.

* PM-5268 - Bump migration verson
This commit is contained in:
Jared Snider
2024-04-01 16:02:58 -04:00
committed by GitHub
parent 94843bdd8b
commit c202c93378
32 changed files with 738 additions and 334 deletions

View File

@@ -1,6 +1,7 @@
import { Observable } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
@@ -10,17 +11,24 @@ export abstract class DeviceTrustCryptoServiceAbstraction {
* @description Retrieves the users choice to trust the device which can only happen after decryption
* Note: this value should only be used once and then reset
*/
getShouldTrustDevice: () => Promise<boolean | null>;
setShouldTrustDevice: (value: boolean) => Promise<void>;
getShouldTrustDevice: (userId: UserId) => Promise<boolean | null>;
setShouldTrustDevice: (userId: UserId, value: boolean) => Promise<void>;
trustDeviceIfRequired: () => Promise<void>;
trustDeviceIfRequired: (userId: UserId) => Promise<void>;
trustDevice: () => Promise<DeviceResponse>;
getDeviceKey: () => Promise<DeviceKey>;
trustDevice: (userId: UserId) => Promise<DeviceResponse>;
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
getDeviceKey: (userId: UserId) => Promise<DeviceKey | null>;
decryptUserKeyWithDeviceKey: (
userId: UserId,
encryptedDevicePrivateKey: EncString,
encryptedUserKey: EncString,
deviceKey?: DeviceKey,
deviceKey: DeviceKey,
) => Promise<UserKey | null>;
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
rotateDevicesTrust: (
userId: UserId,
newUserKey: UserKey,
masterPasswordHash: string,
) => Promise<void>;
}

View File

@@ -9,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserKey, DeviceKey } from "../../types/key";
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
@@ -22,7 +26,25 @@ import {
UpdateDevicesTrustRequest,
} from "../models/request/update-devices-trust.request";
/** Uses disk storage so that the device key can persist after log out and tab removal. */
export const DEVICE_KEY = new KeyDefinition<DeviceKey>(DEVICE_TRUST_DISK_LOCAL, "deviceKey", {
deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey,
});
/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */
export const SHOULD_TRUST_DEVICE = new KeyDefinition<boolean>(
DEVICE_TRUST_DISK_LOCAL,
"shouldTrustDevice",
{
deserializer: (shouldTrustDevice) => shouldTrustDevice,
},
);
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
private readonly platformSupportsSecureStorage =
this.platformUtilsService.supportsSecureStorage();
private readonly deviceKeySecureStorageKey: string = "_deviceKey";
supportsDeviceTrust$: Observable<boolean>;
constructor(
@@ -30,11 +52,12 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
private cryptoFunctionService: CryptoFunctionService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private stateService: StateService,
private appIdService: AppIdService,
private devicesApiService: DevicesApiServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private stateProvider: StateProvider,
private secureStorageService: AbstractStorageService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
@@ -46,24 +69,44 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
* @description Retrieves the users choice to trust the device which can only happen after decryption
* Note: this value should only be used once and then reset
*/
async getShouldTrustDevice(): Promise<boolean> {
return await this.stateService.getShouldTrustDevice();
async getShouldTrustDevice(userId: UserId): Promise<boolean> {
if (!userId) {
throw new Error("UserId is required. Cannot get should trust device.");
}
const shouldTrustDevice = await firstValueFrom(
this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId),
);
return shouldTrustDevice;
}
async setShouldTrustDevice(value: boolean): Promise<void> {
await this.stateService.setShouldTrustDevice(value);
async setShouldTrustDevice(userId: UserId, value: boolean): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set should trust device.");
}
await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId);
}
async trustDeviceIfRequired(): Promise<void> {
const shouldTrustDevice = await this.getShouldTrustDevice();
async trustDeviceIfRequired(userId: UserId): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot trust device if required.");
}
const shouldTrustDevice = await this.getShouldTrustDevice(userId);
if (shouldTrustDevice) {
await this.trustDevice();
await this.trustDevice(userId);
// reset the trust choice
await this.setShouldTrustDevice(false);
await this.setShouldTrustDevice(userId, false);
}
}
async trustDevice(): Promise<DeviceResponse> {
async trustDevice(userId: UserId): Promise<DeviceResponse> {
if (!userId) {
throw new Error("UserId is required. Cannot trust device.");
}
// Attempt to get user key
const userKey: UserKey = await this.cryptoService.getUserKey();
@@ -104,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
);
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(deviceKey);
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
return deviceResponse;
}
async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise<void> {
const currentDeviceKey = await this.getDeviceKey();
async rotateDevicesTrust(
userId: UserId,
newUserKey: UserKey,
masterPasswordHash: string,
): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot rotate device's trust.");
}
const currentDeviceKey = await this.getDeviceKey(userId);
if (currentDeviceKey == null) {
// If the current device doesn't have a device key available to it, then we can't
// rotate any trust at all, so early return.
@@ -165,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier);
}
async getDeviceKey(): Promise<DeviceKey> {
return await this.stateService.getDeviceKey();
async getDeviceKey(userId: UserId): Promise<DeviceKey | null> {
if (!userId) {
throw new Error("UserId is required. Cannot get device key.");
}
if (this.platformSupportsSecureStorage) {
const deviceKeyB64 = await this.secureStorageService.get<
ReturnType<SymmetricCryptoKey["toJSON"]>
>(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey;
return deviceKey;
}
const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId));
return deviceKey;
}
private async setDeviceKey(deviceKey: DeviceKey | null): Promise<void> {
await this.stateService.setDeviceKey(deviceKey);
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set device key.");
}
if (this.platformSupportsSecureStorage) {
await this.secureStorageService.save<DeviceKey>(
`${userId}${this.deviceKeySecureStorageKey}`,
deviceKey,
this.getSecureStorageOptions(userId),
);
return;
}
await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId);
}
private async makeDeviceKey(): Promise<DeviceKey> {
// Create 512-bit device key
return (await this.keyGenerationService.createKey(512)) as DeviceKey;
const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey;
return deviceKey;
}
async decryptUserKeyWithDeviceKey(
userId: UserId,
encryptedDevicePrivateKey: EncString,
encryptedUserKey: EncString,
deviceKey?: DeviceKey,
deviceKey: DeviceKey,
): Promise<UserKey | null> {
// If device key provided use it, otherwise try to retrieve from storage
deviceKey ||= await this.getDeviceKey();
if (!userId) {
throw new Error("UserId is required. Cannot decrypt user key with device key.");
}
if (!deviceKey) {
// User doesn't have a device key anymore so device is untrusted
@@ -207,9 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
return new SymmetricCryptoKey(userKey) as UserKey;
} catch (e) {
// If either decryption effort fails, we want to remove the device key
await this.setDeviceKey(null);
await this.setDeviceKey(userId, null);
return null;
}
}
private getSecureStorageOptions(userId: UserId): StorageOptions {
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: userId,
};
}
}

View File

@@ -4,6 +4,9 @@ import { BehaviorSubject, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../spec/fake-state";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { DeviceType } from "../../enums";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
@@ -12,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
import { Utils } from "../../platform/misc/utils";
import { EncString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { DeviceKey, UserKey } from "../../types/key";
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
import {
SHOULD_TRUST_DEVICE,
DEVICE_KEY,
DeviceTrustCryptoService,
} from "./device-trust-crypto.service.implementation";
describe("deviceTrustCryptoService", () => {
let deviceTrustCryptoService: DeviceTrustCryptoService;
@@ -32,33 +43,34 @@ describe("deviceTrustCryptoService", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
const stateService = mock<StateService>();
const appIdService = mock<AppIdService>();
const devicesApiService = mock<DevicesApiServiceAbstraction>();
const i18nService = mock<I18nService>();
const platformUtilsService = mock<PlatformUtilsService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const secureStorageService = mock<AbstractStorageService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
let stateProvider: FakeStateProvider;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
const deviceKeyPartialSecureStorageKey = "_deviceKey";
const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`;
const secureStorageOptions: StorageOptions = {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: mockUserId,
};
beforeEach(() => {
jest.clearAllMocks();
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
deviceTrustCryptoService = new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
cryptoService,
encryptService,
stateService,
appIdService,
devicesApiService,
i18nService,
platformUtilsService,
userDecryptionOptionsService,
);
const supportsSecureStorage = false; // default to false; tests will override as needed
// By default all the tests will have a mocked active user in state provider.
deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage);
});
it("instantiates", () => {
@@ -67,27 +79,26 @@ describe("deviceTrustCryptoService", () => {
describe("User Trust Device Choice For Decryption", () => {
describe("getShouldTrustDevice", () => {
it("gets the user trust device choice for decryption from the state service", async () => {
const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice");
it("gets the user trust device choice for decryption", async () => {
const newValue = true;
const expectedValue = true;
stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice();
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId);
expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual(expectedValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
expect(result).toEqual(newValue);
});
});
describe("setShouldTrustDevice", () => {
it("sets the user trust device choice for decryption in the state service", async () => {
const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice");
it("sets the user trust device choice for decryption ", async () => {
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId);
const newValue = true;
await deviceTrustCryptoService.setShouldTrustDevice(newValue);
await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue);
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue);
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
expect(result).toEqual(newValue);
});
});
});
@@ -98,11 +109,11 @@ describe("deviceTrustCryptoService", () => {
jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse);
jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue();
await deviceTrustCryptoService.trustDeviceIfRequired();
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1);
expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1);
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false);
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false);
});
it("should not trust device nor reset when getShouldTrustDevice returns false", async () => {
@@ -112,7 +123,7 @@ describe("deviceTrustCryptoService", () => {
const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice");
const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice");
await deviceTrustCryptoService.trustDeviceIfRequired();
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
expect(trustDeviceSpy).not.toHaveBeenCalled();
@@ -126,53 +137,140 @@ describe("deviceTrustCryptoService", () => {
describe("getDeviceKey", () => {
let existingDeviceKey: DeviceKey;
let stateSvcGetDeviceKeySpy: jest.SpyInstance;
let existingDeviceKeyB64: { keyB64: string };
beforeEach(() => {
existingDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey");
existingDeviceKeyB64 = existingDeviceKey.toJSON();
});
it("returns null when there is not an existing device key", async () => {
stateSvcGetDeviceKeySpy.mockResolvedValue(null);
describe("Secure Storage not supported", () => {
it("returns null when there is not an existing device key", async () => {
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(deviceKey).toBeNull();
expect(secureStorageService.get).not.toHaveBeenCalled();
});
expect(deviceKey).toBeNull();
it("returns the device key when there is an existing device key", async () => {
await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId);
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
expect(secureStorageService.get).not.toHaveBeenCalled();
});
});
it("returns the device key when there is an existing device key", async () => {
stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey);
describe("Secure Storage supported", () => {
beforeEach(() => {
const supportsSecureStorage = true;
deviceTrustCryptoService = createDeviceTrustCryptoService(
mockUserId,
supportsSecureStorage,
);
});
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
it("returns null when there is not an existing device key for the passed in user id", async () => {
secureStorageService.get.mockResolvedValue(null);
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
// Act
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
// Assert
expect(deviceKey).toBeNull();
});
it("returns the device key when there is an existing device key for the passed in user id", async () => {
// Arrange
secureStorageService.get.mockResolvedValue(existingDeviceKeyB64);
// Act
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
// Assert
expect(deviceKey).not.toBeNull();
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
expect(deviceKey).toEqual(existingDeviceKey);
});
});
it("throws an error when no user id is passed in", async () => {
await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow(
"UserId is required. Cannot get device key.",
);
});
});
describe("setDeviceKey", () => {
it("sets the device key in the state service", async () => {
const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey");
describe("Secure Storage not supported", () => {
it("successfully sets the device key in state provider", async () => {
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
const deviceKey = new SymmetricCryptoKey(
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith(
DEVICE_KEY,
newDeviceKey.toJSON(),
mockUserId,
);
});
});
describe("Secure Storage supported", () => {
beforeEach(() => {
const supportsSecureStorage = true;
deviceTrustCryptoService = createDeviceTrustCryptoService(
mockUserId,
supportsSecureStorage,
);
});
it("successfully sets the device key in secure storage", async () => {
// Arrange
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
secureStorageService.get.mockResolvedValue(null);
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// Act
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
// Assert
expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2);
expect(secureStorageService.save).toHaveBeenCalledWith(
deviceKeySecureStorageKey,
newDeviceKey,
secureStorageOptions,
);
});
});
it("throws an error when a null user id is passed in", async () => {
const newDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
) as DeviceKey;
// TypeScript will allow calling private methods if the object is of type 'any'
// This is a hacky workaround, but it allows for cleaner tests
await (deviceTrustCryptoService as any).setDeviceKey(deviceKey);
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey);
await expect(
(deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey),
).rejects.toThrow("UserId is required. Cannot set device key.");
});
});
@@ -300,7 +398,7 @@ describe("deviceTrustCryptoService", () => {
});
it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => {
const response = await deviceTrustCryptoService.trustDevice();
const response = await deviceTrustCryptoService.trustDevice(mockUserId);
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
@@ -331,7 +429,7 @@ describe("deviceTrustCryptoService", () => {
// setup the spy to return null
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
// check if the expected error is thrown
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
);
@@ -341,7 +439,7 @@ describe("deviceTrustCryptoService", () => {
// setup the spy to return undefined
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
// check if the expected error is thrown
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
);
});
@@ -381,7 +479,9 @@ describe("deviceTrustCryptoService", () => {
it(`throws an error if ${method} fails`, async () => {
const methodSpy = spy();
methodSpy.mockRejectedValue(new Error(errorText));
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText);
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
errorText,
);
});
test.each([null, undefined])(
@@ -389,11 +489,17 @@ describe("deviceTrustCryptoService", () => {
async (invalidValue) => {
const methodSpy = spy();
methodSpy.mockResolvedValue(invalidValue);
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow();
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow();
},
);
},
);
it("throws an error when a null user id is passed in", async () => {
await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow(
"UserId is required. Cannot trust device.",
);
});
});
describe("decryptUserKeyWithDeviceKey", () => {
@@ -422,19 +528,26 @@ describe("deviceTrustCryptoService", () => {
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);
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
null,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
),
).rejects.toThrow("UserId is required. Cannot decrypt user key with device key.");
});
it("returns null when device key isn't provided", async () => {
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
);
expect(result).toBeNull();
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
});
it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => {
@@ -446,6 +559,7 @@ describe("deviceTrustCryptoService", () => {
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
@@ -456,31 +570,6 @@ describe("deviceTrustCryptoService", () => {
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));
const rsaDecryptSpy = jest
.spyOn(cryptoService, "rsaDecrypt")
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
// 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")
@@ -488,6 +577,7 @@ describe("deviceTrustCryptoService", () => {
const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey");
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
mockEncryptedUserKey,
mockDeviceKey,
@@ -496,7 +586,7 @@ describe("deviceTrustCryptoService", () => {
expect(result).toBeNull();
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
expect(setDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(setDeviceKeySpy).toHaveBeenCalledWith(null);
expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null);
});
});
@@ -514,19 +604,28 @@ describe("deviceTrustCryptoService", () => {
cryptoService.activeUserKey$ = of(fakeNewUserKey);
});
it("does an early exit when the current device is not a trusted device", async () => {
stateService.getDeviceKey.mockResolvedValue(null);
it("throws an error when a null user id is passed in", async () => {
await expect(
deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""),
).rejects.toThrow("UserId is required. Cannot rotate device's trust.");
});
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "");
it("does an early exit when the current device is not a trusted device", async () => {
const deviceKeyState: FakeActiveUserState<DeviceKey> =
stateProvider.activeUser.getFake(DEVICE_KEY);
deviceKeyState.nextState(null);
await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, "");
expect(devicesApiService.updateTrust).not.toHaveBeenCalled();
});
describe("is on a trusted device", () => {
beforeEach(() => {
stateService.getDeviceKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey,
);
beforeEach(async () => {
const mockDeviceKey = new SymmetricCryptoKey(
new Uint8Array(deviceKeyBytesLength),
) as DeviceKey;
await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId);
});
it("rotates current device keys and calls api service when the current device is trusted", async () => {
@@ -592,7 +691,11 @@ describe("deviceTrustCryptoService", () => {
);
});
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash");
await deviceTrustCryptoService.rotateDevicesTrust(
mockUserId,
fakeNewUserKey,
"my_password_hash",
);
expect(devicesApiService.updateTrust).toHaveBeenCalledWith(
matches((updateTrustModel: UpdateDevicesTrustRequest) => {
@@ -608,4 +711,32 @@ describe("deviceTrustCryptoService", () => {
});
});
});
// Helpers
function createDeviceTrustCryptoService(
mockUserId: UserId | null,
supportsSecureStorage: boolean,
) {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
return new DeviceTrustCryptoService(
keyGenerationService,
cryptoFunctionService,
cryptoService,
encryptService,
appIdService,
devicesApiService,
i18nService,
platformUtilsService,
stateProvider,
secureStorageService,
userDecryptionOptionsService,
);
}
});