[PM-10059] alert server if device trust is lost (#10235)

* alert server if device trust is lost

* add test

* add tests for extra errors

* fix build

---------

Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
This commit is contained in:
Jake Fink 2024-07-24 10:25:57 -04:00 committed by GitHub
parent 768b5393e9
commit 4c26ab5a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 109 additions and 3 deletions

View File

@ -697,6 +697,7 @@ export default class MainBackground {
this.secureStorageService,
this.userDecryptionOptionsService,
this.logService,
this.configService,
);
this.devicesService = new DevicesServiceImplementation(this.devicesApiService);

View File

@ -534,6 +534,7 @@ export class ServiceContainer {
this.secureStorageService,
this.userDecryptionOptionsService,
this.logService,
this.configService,
);
this.authRequestService = new AuthRequestService(

View File

@ -1050,6 +1050,7 @@ const safeProviders: SafeProvider[] = [
SECURE_STORAGE,
UserDecryptionOptionsServiceAbstraction,
LogService,
ConfigService,
],
}),
safeProvider({

View File

@ -312,6 +312,27 @@ describe("SsoLoginStrategy", () => {
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
it("logs when a device key is found but no decryption keys were recieved in token response", async () => {
// Arrange
const userDecryptionOpts = userDecryptionOptsServerResponseWithTdeOption;
userDecryptionOpts.TrustedDeviceOption.EncryptedPrivateKey = null;
userDecryptionOpts.TrustedDeviceOption.EncryptedUserKey = null;
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
null,
userDecryptionOpts,
);
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
deviceTrustService.getDeviceKey.mockResolvedValue(mockDeviceKey);
// Act
await ssoLoginStrategy.logIn(credentials);
// Assert
expect(deviceTrustService.recordDeviceTrustLoss).toHaveBeenCalledTimes(1);
});
describe("AdminAuthRequest", () => {
let tokenResponse: IdentityTokenResponse;

View File

@ -296,16 +296,20 @@ export class SsoLoginStrategy extends LoginStrategy {
if (!deviceKey || !encDevicePrivateKey || !encUserKey) {
if (!deviceKey) {
await this.logService.warning("Unable to set user key due to missing device key.");
this.logService.warning("Unable to set user key due to missing device key.");
} else if (!encDevicePrivateKey || !encUserKey) {
// Tell the server that we have a device key, but received no decryption keys
await this.deviceTrustService.recordDeviceTrustLoss();
}
if (!encDevicePrivateKey) {
await this.logService.warning(
this.logService.warning(
"Unable to set user key due to missing encrypted device private key.",
);
}
if (!encUserKey) {
await this.logService.warning("Unable to set user key due to missing encrypted user key.");
this.logService.warning("Unable to set user key due to missing encrypted user key.");
}
return;
}

View File

@ -32,4 +32,9 @@ export abstract class DeviceTrustServiceAbstraction {
newUserKey: UserKey,
masterPasswordHash: string,
) => Promise<void>;
/**
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.
* Note: For debugging purposes only.
*/
recordDeviceTrustLoss: () => Promise<void>;
}

View File

@ -27,4 +27,11 @@ export abstract class DevicesApiServiceAbstraction {
deviceIdentifier: string,
secretVerificationRequest: SecretVerificationRequest,
) => Promise<ProtectedDeviceResponse>;
/**
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.
* Note: For debugging purposes only.
* @param deviceIdentifier - current device identifier
*/
postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>;
}

View File

@ -2,7 +2,9 @@ import { firstValueFrom, map, Observable } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
@ -68,6 +70,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
private secureStorageService: AbstractStorageService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private logService: LogService,
private configService: ConfigService,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => options?.trustedDeviceOption != null ?? false),
@ -287,6 +290,16 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
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;
@ -315,6 +328,14 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
}
async recordDeviceTrustLoss(): Promise<void> {
if (!(await this.configService.getFeatureFlag(FeatureFlag.DeviceTrustLogging))) {
return;
}
const deviceIdentifier = await this.appIdService.getAppId();
await this.devicesApiService.postDeviceTrustLoss(deviceIdentifier);
}
private getSecureStorageOptions(userId: UserId): StorageOptions {
return {
storageLocation: StorageLocation.Disk,

View File

@ -9,6 +9,7 @@ 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 { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
@ -50,6 +51,7 @@ describe("deviceTrustService", () => {
const platformUtilsService = mock<PlatformUtilsService>();
const secureStorageService = mock<AbstractStorageService>();
const logService = mock<LogService>();
const configService = mock<ConfigService>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
@ -533,6 +535,32 @@ describe("deviceTrustService", () => {
).rejects.toThrow("UserId is required. Cannot decrypt user key with device key.");
});
it("throws an error when a nullish encrypted device private key is passed in", async () => {
await expect(
deviceTrustService.decryptUserKeyWithDeviceKey(
mockUserId,
null,
mockEncryptedUserKey,
mockDeviceKey,
),
).rejects.toThrow(
"Encrypted device private key is required. Cannot decrypt user key with device key.",
);
});
it("throws an error when a nullish encrypted user key is passed in", async () => {
await expect(
deviceTrustService.decryptUserKeyWithDeviceKey(
mockUserId,
mockEncryptedDevicePrivateKey,
null,
mockDeviceKey,
),
).rejects.toThrow(
"Encrypted user key is required. Cannot decrypt user key with device key.",
);
});
it("returns null when device key isn't provided", async () => {
const result = await deviceTrustService.decryptUserKeyWithDeviceKey(
mockUserId,
@ -731,6 +759,7 @@ describe("deviceTrustService", () => {
secureStorageService,
userDecryptionOptionsService,
logService,
configService,
);
}
});

View File

@ -101,4 +101,18 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
);
return new ProtectedDeviceResponse(result);
}
async postDeviceTrustLoss(deviceIdentifier: string): Promise<void> {
await this.apiService.send(
"POST",
"/devices/lost-trust",
null,
true,
false,
null,
(headers) => {
headers.set("Device-Identifier", deviceIdentifier);
},
);
}
}

View File

@ -26,6 +26,7 @@ export enum FeatureFlag {
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
DeviceTrustLogging = "pm-8285-device-trust-logging",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -62,6 +63,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.DeviceTrustLogging]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;