mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
* feat(user-decryption-options) [PM-26413]: Update UserDecryptionOptionsService and tests to use UserId-only APIs. * feat(user-decryption-options) [PM-26413]: Update InternalUserDecryptionOptionsService call sites to use UserId-only API. * feat(user-decryption-options) [PM-26413] Update userDecryptionOptions$ call sites to use the UserId-only API. * feat(user-decryption-options) [PM-26413]: Update additional call sites. * feat(user-decryption-options) [PM-26413]: Update dependencies and an additional call site. * feat(user-verification-service) [PM-26413]: Replace where allowed by unrestricted imports invocation of UserVerificationService.hasMasterPassword (deprecated) with UserDecryptionOptions.hasMasterPasswordById$. Additional work to complete as tech debt tracked in PM-27009. * feat(user-decryption-options) [PM-26413]: Update for non-null strict adherence. * feat(user-decryption-options) [PM-26413]: Update type safety and defensive returns. * chore(user-decryption-options) [PM-26413]: Comment cleanup. * feat(user-decryption-options) [PM-26413]: Update tests. * feat(user-decryption-options) [PM-26413]: Standardize null-checking on active account id for new API consumption. * feat(vault-timeout-settings-service) [PM-26413]: Add test cases to illustrate null active account from AccountService. * fix(fido2-user-verification-service-spec) [PM-26413]: Update test harness to use FakeAccountService. * fix(downstream-components) [PM-26413]: Prefer use of the getUserId operator in all authenticated contexts for user id provided to UserDecryptionOptionsService. --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
// FIXME: Update this file to be type safe and remove this and next line
|
|
// @ts-strict-ignore
|
|
import { firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
|
|
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { KeyService } from "@bitwarden/key-management";
|
|
|
|
import { AccountService } from "../../../auth/abstractions/account.service";
|
|
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
|
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
|
|
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
|
import {
|
|
DeviceKeysUpdateRequest,
|
|
OtherDeviceKeysUpdateRequest,
|
|
UpdateDevicesTrustRequest,
|
|
} from "../../../auth/models/request/update-devices-trust.request";
|
|
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
|
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
|
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
|
import { LogService } from "../../../platform/abstractions/log.service";
|
|
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
|
import { AbstractStorageService } from "../../../platform/abstractions/storage.service";
|
|
import { StorageLocation } from "../../../platform/enums";
|
|
import { StorageOptions } from "../../../platform/models/domain/storage-options";
|
|
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
|
import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
|
import { UserId } from "../../../types/guid";
|
|
import { UserKey, DeviceKey } from "../../../types/key";
|
|
import { KeyGenerationService } from "../../crypto";
|
|
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
|
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
|
import { EncString } from "../../crypto/models/enc-string";
|
|
import { RotateableKeySet } from "../../keys/models/rotateable-key-set";
|
|
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
|
|
|
|
/** Uses disk storage so that the device key can persist after log out and tab removal. */
|
|
export const DEVICE_KEY = new UserKeyDefinition<DeviceKey | null>(
|
|
DEVICE_TRUST_DISK_LOCAL,
|
|
"deviceKey",
|
|
{
|
|
deserializer: (deviceKey) =>
|
|
deviceKey ? (SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey) : null,
|
|
clearOn: [], // Device key is needed to log back into device, so we can't clear it automatically during lock or logout
|
|
cleanupDelayMs: 0,
|
|
debug: {
|
|
enableRetrievalLogging: true,
|
|
enableUpdateLogging: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */
|
|
export const SHOULD_TRUST_DEVICE = new UserKeyDefinition<boolean | null>(
|
|
DEVICE_TRUST_DISK_LOCAL,
|
|
"shouldTrustDevice",
|
|
{
|
|
deserializer: (shouldTrustDevice) => shouldTrustDevice,
|
|
clearOn: [], // Need to preserve the user setting, so we can't clear it automatically during lock or logout
|
|
},
|
|
);
|
|
|
|
export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
|
private readonly platformSupportsSecureStorage =
|
|
this.platformUtilsService.supportsSecureStorage();
|
|
private readonly deviceKeySecureStorageKey: string = "_deviceKey";
|
|
|
|
supportsDeviceTrust$: Observable<boolean>;
|
|
|
|
// Observable emission is used to trigger a toast in consuming components
|
|
private deviceTrustedSubject = new Subject<void>();
|
|
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
|
|
|
|
constructor(
|
|
private keyGenerationService: KeyGenerationService,
|
|
private cryptoFunctionService: CryptoFunctionService,
|
|
private keyService: KeyService,
|
|
private encryptService: EncryptService,
|
|
private appIdService: AppIdService,
|
|
private devicesApiService: DevicesApiServiceAbstraction,
|
|
private i18nService: I18nService,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private stateProvider: StateProvider,
|
|
private secureStorageService: AbstractStorageService,
|
|
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
|
private logService: LogService,
|
|
private configService: ConfigService,
|
|
private accountService: AccountService,
|
|
) {
|
|
this.supportsDeviceTrust$ = this.accountService.activeAccount$.pipe(
|
|
switchMap((account) => {
|
|
if (account == null) {
|
|
return [false];
|
|
}
|
|
return this.userDecryptionOptionsService.userDecryptionOptionsById$(account.id).pipe(
|
|
map((options) => {
|
|
return options?.trustedDeviceOption != null;
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
|
|
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
|
|
map((options) => {
|
|
return options?.trustedDeviceOption != null;
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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(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(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(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(userId);
|
|
// reset the trust choice
|
|
await this.setShouldTrustDevice(userId, null);
|
|
}
|
|
}
|
|
|
|
async trustDevice(userId: UserId): Promise<DeviceResponse> {
|
|
if (!userId) {
|
|
throw new Error("UserId is required. Cannot trust device.");
|
|
}
|
|
|
|
// Attempt to get user key
|
|
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
|
|
|
// If user key is not found, throw error
|
|
if (!userKey) {
|
|
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 [
|
|
devicePublicKeyEncryptedUserKey,
|
|
userKeyEncryptedDevicePublicKey,
|
|
deviceKeyEncryptedDevicePrivateKey,
|
|
] = await Promise.all([
|
|
// Encrypt user key with the DevicePublicKey
|
|
this.encryptService.encapsulateKeyUnsigned(userKey, devicePublicKey),
|
|
|
|
// Encrypt devicePublicKey with user key
|
|
this.encryptService.wrapEncapsulationKey(devicePublicKey, userKey),
|
|
|
|
// Encrypt devicePrivateKey with deviceKey
|
|
this.encryptService.wrapDecapsulationKey(devicePrivateKey, deviceKey),
|
|
]);
|
|
|
|
// Send encrypted keys to server
|
|
const deviceIdentifier = await this.appIdService.getAppId();
|
|
const deviceResponse = await this.devicesApiService.updateTrustedDeviceKeys(
|
|
deviceIdentifier,
|
|
devicePublicKeyEncryptedUserKey.encryptedString,
|
|
userKeyEncryptedDevicePublicKey.encryptedString,
|
|
deviceKeyEncryptedDevicePrivateKey.encryptedString,
|
|
);
|
|
|
|
// store device key in local/secure storage if enc keys posted to server successfully
|
|
await this.setDeviceKey(userId, deviceKey);
|
|
|
|
// This emission will be picked up by consuming components to handle displaying a toast to the user
|
|
this.deviceTrustedSubject.next();
|
|
|
|
return deviceResponse;
|
|
}
|
|
|
|
async getRotatedData(
|
|
oldUserKey: UserKey,
|
|
newUserKey: UserKey,
|
|
userId: UserId,
|
|
): Promise<OtherDeviceKeysUpdateRequest[]> {
|
|
if (!userId) {
|
|
throw new Error("UserId is required. Cannot get rotated data.");
|
|
}
|
|
if (!oldUserKey) {
|
|
throw new Error("Old user key is required. Cannot get rotated data.");
|
|
}
|
|
if (!newUserKey) {
|
|
throw new Error("New user key is required. Cannot get rotated data.");
|
|
}
|
|
|
|
const devices = await this.devicesApiService.getDevices();
|
|
const devicesToUntrust: string[] = [];
|
|
const rotatedData = await Promise.all(
|
|
devices.data
|
|
.filter((device) => device.isTrusted)
|
|
.map(async (device) => {
|
|
const publicKey = await this.encryptService.unwrapEncapsulationKey(
|
|
new EncString(device.encryptedPublicKey),
|
|
oldUserKey,
|
|
);
|
|
|
|
if (!publicKey) {
|
|
// Device was trusted but encryption is broken. This should be untrusted
|
|
devicesToUntrust.push(device.id);
|
|
return null;
|
|
}
|
|
|
|
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
|
publicKey,
|
|
newUserKey,
|
|
);
|
|
const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
|
newUserKey,
|
|
publicKey,
|
|
);
|
|
|
|
const newRotateableKeySet = new RotateableKeySet(
|
|
newEncryptedUserKey,
|
|
newEncryptedPublicKey,
|
|
);
|
|
|
|
const request = new OtherDeviceKeysUpdateRequest();
|
|
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
|
|
request.encryptedUserKey = newRotateableKeySet.encapsulatedDownstreamKey.encryptedString;
|
|
request.deviceId = device.id;
|
|
return request;
|
|
})
|
|
.filter((otherDeviceKeysUpdateRequest) => otherDeviceKeysUpdateRequest != null),
|
|
);
|
|
if (rotatedData.length > 0) {
|
|
this.logService.info("[Device trust rotation] Distrusting devices that failed to decrypt.");
|
|
await this.devicesApiService.untrustDevices(devicesToUntrust);
|
|
}
|
|
return rotatedData;
|
|
}
|
|
|
|
async rotateDevicesTrust(
|
|
userId: UserId,
|
|
newUserKey: UserKey,
|
|
masterPasswordHash: string,
|
|
): Promise<void> {
|
|
this.logService.info("[Device trust rotation] Rotating device trust...");
|
|
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.
|
|
this.logService.info("[Device trust rotation] No device key available to rotate trust!");
|
|
return;
|
|
}
|
|
|
|
// At this point of rotating their keys, they should still have their old user key in state
|
|
const oldUserKey = await firstValueFrom(this.keyService.userKey$(userId));
|
|
if (oldUserKey == newUserKey) {
|
|
this.logService.info("[Device trust rotation] Old user key is the same as the new user key.");
|
|
}
|
|
|
|
const deviceIdentifier = await this.appIdService.getAppId();
|
|
const secretVerificationRequest = new SecretVerificationRequest();
|
|
secretVerificationRequest.masterPasswordHash = masterPasswordHash;
|
|
|
|
// Get the keys that are used in rotating a devices keys from the server
|
|
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
|
|
|
|
// Decrypt the existing device public key with the old user key
|
|
const decryptedDevicePublicKey = await this.encryptService.unwrapEncapsulationKey(
|
|
currentDeviceKeys.encryptedPublicKey,
|
|
oldUserKey,
|
|
);
|
|
|
|
// Encrypt the brand new user key with the now-decrypted public key for the device
|
|
const encryptedNewUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
|
newUserKey,
|
|
decryptedDevicePublicKey,
|
|
);
|
|
|
|
// Re-encrypt the device public key with the new user key
|
|
const encryptedDevicePublicKey = await this.encryptService.wrapEncapsulationKey(
|
|
decryptedDevicePublicKey,
|
|
newUserKey,
|
|
);
|
|
|
|
const currentDeviceUpdateRequest = new DeviceKeysUpdateRequest();
|
|
currentDeviceUpdateRequest.encryptedUserKey = encryptedNewUserKey.encryptedString;
|
|
currentDeviceUpdateRequest.encryptedPublicKey = encryptedDevicePublicKey.encryptedString;
|
|
|
|
// TODO: For device management, allow this method to take an array of device ids that can be looped over and individually rotated
|
|
// then it can be added to trustRequest.otherDevices.
|
|
|
|
const trustRequest = new UpdateDevicesTrustRequest();
|
|
trustRequest.masterPasswordHash = masterPasswordHash;
|
|
trustRequest.currentDevice = currentDeviceUpdateRequest;
|
|
trustRequest.otherDevices = [];
|
|
|
|
this.logService.info(
|
|
"[Device trust rotation] Posting device trust update with current device:",
|
|
deviceIdentifier,
|
|
);
|
|
await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier);
|
|
this.logService.info("[Device trust rotation] Device trust update posted successfully.");
|
|
}
|
|
|
|
async getDeviceKey(userId: UserId): Promise<DeviceKey | null> {
|
|
if (!userId) {
|
|
throw new Error("UserId is required. Cannot get device key.");
|
|
}
|
|
|
|
try {
|
|
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;
|
|
} catch (e) {
|
|
this.logService.error("Failed to get device key", e);
|
|
}
|
|
}
|
|
|
|
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
|
if (!userId) {
|
|
throw new Error("UserId is required. Cannot set device key.");
|
|
}
|
|
|
|
try {
|
|
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);
|
|
} catch (e) {
|
|
this.logService.error("Failed to set device key", e);
|
|
}
|
|
}
|
|
|
|
private async makeDeviceKey(): Promise<DeviceKey> {
|
|
// Create 512-bit device key
|
|
const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey;
|
|
|
|
return deviceKey;
|
|
}
|
|
|
|
async decryptUserKeyWithDeviceKey(
|
|
userId: UserId,
|
|
encryptedDevicePrivateKey: EncString,
|
|
encryptedUserKey: EncString,
|
|
deviceKey: DeviceKey,
|
|
): Promise<UserKey | null> {
|
|
if (!userId) {
|
|
throw new Error("UserId is required. Cannot decrypt user key with device key.");
|
|
}
|
|
|
|
if (!encryptedDevicePrivateKey) {
|
|
throw new Error(
|
|
"Encrypted device private key is required. Cannot decrypt user key with device key.",
|
|
);
|
|
}
|
|
|
|
if (!encryptedUserKey) {
|
|
throw new Error("Encrypted user key is required. Cannot decrypt user key with device key.");
|
|
}
|
|
|
|
if (!deviceKey) {
|
|
// User doesn't have a device key anymore so device is untrusted
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// attempt to decrypt encryptedDevicePrivateKey with device key
|
|
const devicePrivateKey = await this.encryptService.unwrapDecapsulationKey(
|
|
encryptedDevicePrivateKey,
|
|
deviceKey,
|
|
);
|
|
|
|
// Attempt to decrypt encryptedUserDataKey with devicePrivateKey
|
|
const userKey = await this.encryptService.decapsulateKeyUnsigned(
|
|
new EncString(encryptedUserKey.encryptedString),
|
|
devicePrivateKey,
|
|
);
|
|
|
|
return userKey as UserKey;
|
|
// FIXME: Remove when updating file. Eslint update
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
} catch (e) {
|
|
// If either decryption effort fails, we want to remove the device key
|
|
this.logService.error("Failed to decrypt using device key. Removing device key.");
|
|
await this.setDeviceKey(userId, null);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async recordDeviceTrustLoss(): Promise<void> {
|
|
const deviceIdentifier = await this.appIdService.getAppId();
|
|
await this.devicesApiService.postDeviceTrustLoss(deviceIdentifier);
|
|
}
|
|
|
|
private getSecureStorageOptions(userId: UserId): StorageOptions {
|
|
return {
|
|
storageLocation: StorageLocation.Disk,
|
|
useSecureStorage: true,
|
|
userId: userId,
|
|
};
|
|
}
|
|
}
|