mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
[PM-14445] TS strict for Key Management Biometrics (#13039)
* PM-14445: TS strict for Key Management Biometrics * formatting * callbacks not null expectations * state nullability expectations updates * unit tests fix * secure channel naming, explicit null check on messageId * revert null for getUser, getGlobal in state.provider.ts * revert null for getUser, getGlobal in state.provider.ts
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -57,26 +55,29 @@ type ReceiveMessageOuter = {
|
||||
messageId?: number;
|
||||
|
||||
// Should only have one of these.
|
||||
message?: EncString;
|
||||
message?: ReceiveMessage | EncString;
|
||||
sharedSecret?: string;
|
||||
};
|
||||
|
||||
type Callback = {
|
||||
resolver: any;
|
||||
rejecter: any;
|
||||
resolver: (value?: unknown) => void;
|
||||
rejecter: (reason?: any) => void;
|
||||
};
|
||||
|
||||
type SecureChannel = {
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
sharedSecret?: SymmetricCryptoKey;
|
||||
setupResolve: (value?: unknown) => void;
|
||||
};
|
||||
|
||||
export class NativeMessagingBackground {
|
||||
connected = false;
|
||||
private connecting: boolean;
|
||||
private port: browser.runtime.Port | chrome.runtime.Port;
|
||||
private connecting: boolean = false;
|
||||
private port?: browser.runtime.Port | chrome.runtime.Port;
|
||||
private appId?: string;
|
||||
|
||||
private privateKey: Uint8Array = null;
|
||||
private publicKey: Uint8Array = null;
|
||||
private secureSetupResolve: any = null;
|
||||
private sharedSecret: SymmetricCryptoKey;
|
||||
private appId: string;
|
||||
private validatingFingerprint: boolean;
|
||||
private secureChannel?: SecureChannel;
|
||||
|
||||
private messageId = 0;
|
||||
private callbacks = new Map<number, Callback>();
|
||||
@@ -108,11 +109,13 @@ export class NativeMessagingBackground {
|
||||
|
||||
async connect() {
|
||||
this.logService.info("[Native Messaging IPC] Connecting to Bitwarden Desktop app...");
|
||||
this.appId = await this.appIdService.getAppId();
|
||||
const appId = await this.appIdService.getAppId();
|
||||
this.appId = appId;
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.port = BrowserApi.connectNative("com.8bit.bitwarden");
|
||||
const port = BrowserApi.connectNative("com.8bit.bitwarden");
|
||||
this.port = port;
|
||||
|
||||
this.connecting = true;
|
||||
|
||||
@@ -131,7 +134,8 @@ export class NativeMessagingBackground {
|
||||
connectedCallback();
|
||||
}
|
||||
|
||||
this.port.onMessage.addListener(async (message: ReceiveMessageOuter) => {
|
||||
port.onMessage.addListener(async (messageRaw: unknown) => {
|
||||
const message = messageRaw as ReceiveMessageOuter;
|
||||
switch (message.command) {
|
||||
case "connected":
|
||||
connectedCallback();
|
||||
@@ -142,7 +146,7 @@ export class NativeMessagingBackground {
|
||||
reject(new Error("startDesktop"));
|
||||
}
|
||||
this.connected = false;
|
||||
this.port.disconnect();
|
||||
port.disconnect();
|
||||
// reject all
|
||||
for (const callback of this.callbacks.values()) {
|
||||
callback.rejecter("disconnected");
|
||||
@@ -151,18 +155,31 @@ export class NativeMessagingBackground {
|
||||
break;
|
||||
case "setupEncryption": {
|
||||
// Ignore since it belongs to another device
|
||||
if (message.appId !== this.appId) {
|
||||
if (message.appId !== appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.sharedSecret == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Unable to create secureChannel channel, no shared secret",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.secureChannel == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Unable to create secureChannel channel, no secureChannel communication setup",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = Utils.fromB64ToArray(message.sharedSecret);
|
||||
const decrypted = await this.cryptoFunctionService.rsaDecrypt(
|
||||
encrypted,
|
||||
this.privateKey,
|
||||
this.secureChannel.privateKey,
|
||||
HashAlgorithmForEncryption,
|
||||
);
|
||||
|
||||
this.sharedSecret = new SymmetricCryptoKey(decrypted);
|
||||
this.secureChannel.sharedSecret = new SymmetricCryptoKey(decrypted);
|
||||
this.logService.info("[Native Messaging IPC] Secure channel established");
|
||||
|
||||
if ("messageId" in message) {
|
||||
@@ -173,27 +190,28 @@ export class NativeMessagingBackground {
|
||||
this.isConnectedToOutdatedDesktopClient = true;
|
||||
}
|
||||
|
||||
this.secureSetupResolve();
|
||||
this.secureChannel.setupResolve();
|
||||
break;
|
||||
}
|
||||
case "invalidateEncryption":
|
||||
// Ignore since it belongs to another device
|
||||
if (message.appId !== this.appId) {
|
||||
if (message.appId !== appId) {
|
||||
return;
|
||||
}
|
||||
this.logService.warning(
|
||||
"[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.secureChannel = undefined;
|
||||
this.connected = false;
|
||||
|
||||
if (message.messageId != null) {
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
this.callbacks.get(message.messageId)?.rejecter({
|
||||
message: "invalidateEncryption",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
case "verifyFingerprint": {
|
||||
this.logService.info("[Native Messaging IPC] Legacy app is requesting fingerprint");
|
||||
@@ -217,22 +235,26 @@ export class NativeMessagingBackground {
|
||||
break;
|
||||
}
|
||||
case "wrongUserId":
|
||||
if (message.messageId != null) {
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
this.callbacks.get(message.messageId)?.rejecter({
|
||||
message: "wrongUserId",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
default:
|
||||
// Ignore since it belongs to another device
|
||||
if (!this.platformUtilsService.isSafari() && message.appId !== this.appId) {
|
||||
if (!this.platformUtilsService.isSafari() && message.appId !== appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.message != null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.onMessage(message.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.port.onDisconnect.addListener((p: any) => {
|
||||
@@ -240,16 +262,15 @@ export class NativeMessagingBackground {
|
||||
if (BrowserApi.isWebExtensionsApi) {
|
||||
error = p.error.message;
|
||||
} else {
|
||||
error = chrome.runtime.lastError.message;
|
||||
error = chrome.runtime.lastError?.message;
|
||||
}
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.secureChannel = undefined;
|
||||
this.connected = false;
|
||||
|
||||
this.logService.error("NativeMessaging port disconnected because of error: " + error);
|
||||
|
||||
const reason = error != null ? "desktopIntegrationDisabled" : null;
|
||||
const reason = error != null ? "desktopIntegrationDisabled" : undefined;
|
||||
reject(new Error(reason));
|
||||
});
|
||||
});
|
||||
@@ -293,13 +314,13 @@ export class NativeMessagingBackground {
|
||||
);
|
||||
const callback = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
callback.rejecter("errorConnecting");
|
||||
callback?.rejecter("errorConnecting");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.logService.info("[Native Messaging IPC] Message timed out and received no response");
|
||||
this.callbacks.get(messageId).rejecter({
|
||||
this.callbacks.get(messageId)!.rejecter({
|
||||
message: "timeout",
|
||||
});
|
||||
this.callbacks.delete(messageId);
|
||||
@@ -320,16 +341,19 @@ export class NativeMessagingBackground {
|
||||
if (this.platformUtilsService.isSafari()) {
|
||||
this.postMessage(message as any);
|
||||
} else {
|
||||
this.postMessage({ appId: this.appId, message: await this.encryptMessage(message) });
|
||||
this.postMessage({ appId: this.appId!, message: await this.encryptMessage(message) });
|
||||
}
|
||||
}
|
||||
|
||||
async encryptMessage(message: Message) {
|
||||
if (this.sharedSecret == null) {
|
||||
if (this.secureChannel?.sharedSecret == null) {
|
||||
await this.secureCommunication();
|
||||
}
|
||||
|
||||
return await this.encryptService.encrypt(JSON.stringify(message), this.sharedSecret);
|
||||
return await this.encryptService.encrypt(
|
||||
JSON.stringify(message),
|
||||
this.secureChannel!.sharedSecret!,
|
||||
);
|
||||
}
|
||||
|
||||
private postMessage(message: OuterMessage, messageId?: number) {
|
||||
@@ -346,7 +370,7 @@ export class NativeMessagingBackground {
|
||||
mac: message.message.mac,
|
||||
};
|
||||
}
|
||||
this.port.postMessage(msg);
|
||||
this.port!.postMessage(msg);
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
@@ -354,26 +378,30 @@ export class NativeMessagingBackground {
|
||||
"[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.secureChannel = undefined;
|
||||
this.connected = false;
|
||||
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).rejecter("invalidateEncryption");
|
||||
if (messageId != null && this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId)!.rejecter("invalidateEncryption");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async onMessage(rawMessage: ReceiveMessage | EncString) {
|
||||
let message = rawMessage as ReceiveMessage;
|
||||
let message: ReceiveMessage;
|
||||
if (!this.platformUtilsService.isSafari()) {
|
||||
if (this.secureChannel?.sharedSecret == null) {
|
||||
return;
|
||||
}
|
||||
message = JSON.parse(
|
||||
await this.encryptService.decryptToUtf8(
|
||||
rawMessage as EncString,
|
||||
this.sharedSecret,
|
||||
this.secureChannel.sharedSecret,
|
||||
"ipc-desktop-ipc-channel-key",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
message = rawMessage as ReceiveMessage;
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
@@ -390,15 +418,17 @@ export class NativeMessagingBackground {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Received legacy message of type ${message.command}`,
|
||||
);
|
||||
const messageId = this.callbacks.keys().next().value;
|
||||
const messageId: number | undefined = this.callbacks.keys().next().value;
|
||||
if (messageId != null) {
|
||||
const resolver = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
resolver.resolver(message);
|
||||
resolver!.resolver(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).resolver(message);
|
||||
this.callbacks.get(messageId)!.resolver(message);
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
|
||||
}
|
||||
@@ -406,8 +436,6 @@ export class NativeMessagingBackground {
|
||||
|
||||
private async secureCommunication() {
|
||||
const [publicKey, privateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
this.publicKey = publicKey;
|
||||
this.privateKey = privateKey;
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
@@ -419,7 +447,13 @@ export class NativeMessagingBackground {
|
||||
messageId: this.messageId++,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => (this.secureSetupResolve = resolve));
|
||||
return new Promise((resolve) => {
|
||||
this.secureChannel = {
|
||||
publicKey,
|
||||
privateKey,
|
||||
setupResolve: resolve,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async sendUnencrypted(message: Message) {
|
||||
@@ -429,11 +463,17 @@ export class NativeMessagingBackground {
|
||||
|
||||
message.timestamp = Date.now();
|
||||
|
||||
this.postMessage({ appId: this.appId, message: message });
|
||||
this.postMessage({ appId: this.appId!, message: message });
|
||||
}
|
||||
|
||||
private async showFingerprintDialog() {
|
||||
const fingerprint = await this.keyService.getFingerprint(this.appId, this.publicKey);
|
||||
if (this.secureChannel?.publicKey == null) {
|
||||
return;
|
||||
}
|
||||
const fingerprint = await this.keyService.getFingerprint(
|
||||
this.appId!,
|
||||
this.secureChannel.publicKey,
|
||||
);
|
||||
|
||||
this.messagingService.send("showNativeMessagingFingerprintDialog", {
|
||||
fingerprint: fingerprint,
|
||||
|
||||
@@ -32,6 +32,9 @@ export class MainBiometricsIPCListener {
|
||||
case BiometricAction.GetStatusForUser:
|
||||
return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId);
|
||||
case BiometricAction.SetKeyForUser:
|
||||
if (message.key == null) {
|
||||
return;
|
||||
}
|
||||
return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
@@ -41,6 +44,9 @@ export class MainBiometricsIPCListener {
|
||||
message.userId as UserId,
|
||||
);
|
||||
case BiometricAction.SetClientKeyHalf:
|
||||
if (message.key == null) {
|
||||
return;
|
||||
}
|
||||
return await this.biometricService.setClientKeyHalfForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
|
||||
@@ -25,10 +25,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private biometricStateService: BiometricStateService,
|
||||
) {
|
||||
super();
|
||||
this.loadOsBiometricService(this.platform);
|
||||
}
|
||||
|
||||
private loadOsBiometricService(platform: NodeJS.Platform) {
|
||||
if (platform === "win32") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
|
||||
@@ -117,13 +113,16 @@ export class MainBiometricsService extends DesktopBiometricsService {
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return SymmetricCryptoKey.fromString(
|
||||
await this.osBiometricsService.getBiometricKey(
|
||||
const biometricKey = await this.osBiometricsService.getBiometricKey(
|
||||
"Bitwarden_biometric",
|
||||
`${userId}_user_biometric`,
|
||||
this.clientKeyHalves.get(userId),
|
||||
),
|
||||
) as UserKey;
|
||||
);
|
||||
if (biometricKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SymmetricCryptoKey.fromString(biometricKey) as UserKey;
|
||||
}
|
||||
|
||||
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { spawn } from "child_process";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -138,23 +136,27 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv: string) {
|
||||
this._iv = iv;
|
||||
private setIv(iv?: string) {
|
||||
this._iv = iv ?? null;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string;
|
||||
clientKeyHalfB64: string | undefined;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
|
||||
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verefication!
|
||||
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verification!
|
||||
this._osKeyHalf = keyMaterial.keyB64;
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
if (this._iv == null) {
|
||||
throw new Error("Initialization Vector is null");
|
||||
}
|
||||
|
||||
return {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
@@ -104,7 +102,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string;
|
||||
clientKeyHalfB64: string | undefined;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
// Prompts Windows Hello
|
||||
@@ -113,6 +111,10 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
if (this._iv == null) {
|
||||
throw new Error("Initialization Vector is null");
|
||||
}
|
||||
|
||||
const result = {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
@@ -130,8 +132,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv: string) {
|
||||
this._iv = iv;
|
||||
private setIv(iv?: string) {
|
||||
this._iv = iv ?? null;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
@@ -149,9 +151,9 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
encryptedValue: EncString,
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyPartB64: string,
|
||||
clientKeyPartB64: string | undefined,
|
||||
) {
|
||||
if (encryptedValue.iv == null || encryptedValue == null) {
|
||||
if (encryptedValue.iv == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +185,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
storageKey,
|
||||
}: {
|
||||
value: SymmetricCryptoKey;
|
||||
clientKeyPartB64: string;
|
||||
clientKeyPartB64: string | undefined;
|
||||
service: string;
|
||||
storageKey: string;
|
||||
}): Promise<boolean> {
|
||||
@@ -214,7 +216,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
/** Derives a witness key from a symmetric key being stored for biometric protection */
|
||||
private witnessKeyMaterial(
|
||||
symmetricKey: SymmetricCryptoKey,
|
||||
clientKeyPartB64: string,
|
||||
clientKeyPartB64: string | undefined,
|
||||
): biometrics.KeyMaterial {
|
||||
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NgZone } from "@angular/core";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -27,7 +27,7 @@ import { BiometricMessageHandlerService } from "./biometric-message-handler.serv
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const accounts = {
|
||||
const accounts: Record<UserId, AccountInfo> = {
|
||||
[SomeUser]: {
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
@@ -108,6 +108,30 @@ describe("BiometricMessageHandlerService", () => {
|
||||
});
|
||||
|
||||
describe("setup encryption", () => {
|
||||
it("should ignore when public key missing in message", async () => {
|
||||
await service.handleMessage({
|
||||
appId: "appId",
|
||||
message: {
|
||||
command: "setupEncryption",
|
||||
messageId: 0,
|
||||
userId: "unknownUser" as UserId,
|
||||
},
|
||||
});
|
||||
expect((global as any).ipc.platform.nativeMessaging.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore when user id missing in message", async () => {
|
||||
await service.handleMessage({
|
||||
appId: "appId",
|
||||
message: {
|
||||
command: "setupEncryption",
|
||||
messageId: 0,
|
||||
publicKey: Utils.fromUtf8ToB64("publicKey"),
|
||||
},
|
||||
});
|
||||
expect((global as any).ipc.platform.nativeMessaging.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reject when user is not in app", async () => {
|
||||
await service.handleMessage({
|
||||
appId: "appId",
|
||||
@@ -115,6 +139,7 @@ describe("BiometricMessageHandlerService", () => {
|
||||
command: "setupEncryption",
|
||||
messageId: 0,
|
||||
userId: "unknownUser" as UserId,
|
||||
publicKey: Utils.fromUtf8ToB64("publicKey"),
|
||||
},
|
||||
});
|
||||
expect((global as any).ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({
|
||||
@@ -362,12 +387,15 @@ describe("BiometricMessageHandlerService", () => {
|
||||
// always reload when another user is active than the requested one
|
||||
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, false, true],
|
||||
[SomeUser, AuthenticationStatus.Locked, AnotherUser, false, true],
|
||||
// don't reload when no active user
|
||||
[null, AuthenticationStatus.Unlocked, AnotherUser, false, false],
|
||||
|
||||
// don't reload in dev mode
|
||||
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Locked, SomeUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Locked, AnotherUser, true, false],
|
||||
[null, AuthenticationStatus.Unlocked, AnotherUser, true, false],
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Injectable, NgZone } from "@angular/core";
|
||||
import { combineLatest, concatMap, firstValueFrom, map } from "rxjs";
|
||||
|
||||
@@ -25,8 +23,7 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
|
||||
import { LegacyMessage } from "../models/native-messaging/legacy-message";
|
||||
import { LegacyMessageWrapper } from "../models/native-messaging/legacy-message-wrapper";
|
||||
import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging";
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
const MessageValidTimeout = 10 * 1000;
|
||||
@@ -34,14 +31,14 @@ const HashAlgorithmForAsymmetricEncryption = "sha1";
|
||||
|
||||
type ConnectedApp = {
|
||||
publicKey: string;
|
||||
sessionSecret: string;
|
||||
sessionSecret: string | null;
|
||||
trusted: boolean;
|
||||
};
|
||||
|
||||
const ConnectedAppPrefix = "connectedApp_";
|
||||
|
||||
class ConnectedApps {
|
||||
async get(appId: string): Promise<ConnectedApp> {
|
||||
async get(appId: string): Promise<ConnectedApp | null> {
|
||||
if (!(await this.has(appId))) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,6 +109,12 @@ export class BiometricMessageHandlerService {
|
||||
|
||||
// Request to setup secure encryption
|
||||
if ("command" in rawMessage && rawMessage.command === "setupEncryption") {
|
||||
if (rawMessage.publicKey == null || rawMessage.userId == null) {
|
||||
this.logService.warning(
|
||||
"[Native Messaging IPC] Received invalid setupEncryption message. Ignoring.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const remotePublicKey = Utils.fromB64ToArray(rawMessage.publicKey);
|
||||
|
||||
// Validate the UserId to ensure we are logged into the same account.
|
||||
@@ -134,16 +137,18 @@ export class BiometricMessageHandlerService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.connectedApps.set(appId, {
|
||||
const connectedApp = {
|
||||
publicKey: Utils.fromBufferToB64(remotePublicKey),
|
||||
sessionSecret: null,
|
||||
trusted: false,
|
||||
});
|
||||
await this.secureCommunication(remotePublicKey, appId);
|
||||
} as ConnectedApp;
|
||||
await this.connectedApps.set(appId, connectedApp);
|
||||
await this.secureCommunication(connectedApp, remotePublicKey, appId);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await this.connectedApps.get(appId))?.sessionSecret == null) {
|
||||
const sessionSecret = (await this.connectedApps.get(appId))?.sessionSecret;
|
||||
if (sessionSecret == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Session secret for secure channel is missing. Invalidating encryption...",
|
||||
);
|
||||
@@ -157,7 +162,7 @@ export class BiometricMessageHandlerService {
|
||||
const message: LegacyMessage = JSON.parse(
|
||||
await this.encryptService.decryptToUtf8(
|
||||
rawMessage as EncString,
|
||||
SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret),
|
||||
SymmetricCryptoKey.fromString(sessionSecret),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -173,7 +178,10 @@ export class BiometricMessageHandlerService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
if (
|
||||
message.timestamp == null ||
|
||||
Math.abs(message.timestamp - Date.now()) > MessageValidTimeout
|
||||
) {
|
||||
this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
|
||||
return;
|
||||
}
|
||||
@@ -277,11 +285,11 @@ export class BiometricMessageHandlerService {
|
||||
return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId);
|
||||
}
|
||||
|
||||
const biometricUnlockPromise =
|
||||
const biometricUnlock =
|
||||
message.userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId);
|
||||
if (!(await biometricUnlockPromise)) {
|
||||
? await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId);
|
||||
if (!biometricUnlock) {
|
||||
await this.send({ command: "biometricUnlock", response: "not enabled" }, appId);
|
||||
|
||||
return this.ngZone.run(() =>
|
||||
@@ -310,13 +318,13 @@ export class BiometricMessageHandlerService {
|
||||
|
||||
const currentlyActiveAccountId = (
|
||||
await firstValueFrom(this.accountService.activeAccount$)
|
||||
).id;
|
||||
)?.id;
|
||||
const isCurrentlyActiveAccountUnlocked =
|
||||
(await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked;
|
||||
|
||||
// prevent proc reloading an active account, when it is the same as the browser
|
||||
if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) {
|
||||
await ipc.platform.reloadProcess();
|
||||
ipc.platform.reloadProcess();
|
||||
}
|
||||
} else {
|
||||
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
|
||||
@@ -337,9 +345,14 @@ export class BiometricMessageHandlerService {
|
||||
private async send(message: any, appId: string) {
|
||||
message.timestamp = Date.now();
|
||||
|
||||
const sessionSecret = (await this.connectedApps.get(appId))?.sessionSecret;
|
||||
if (sessionSecret == null) {
|
||||
throw new Error("Session secret is missing");
|
||||
}
|
||||
|
||||
const encrypted = await this.encryptService.encrypt(
|
||||
JSON.stringify(message),
|
||||
SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret),
|
||||
SymmetricCryptoKey.fromString(sessionSecret),
|
||||
);
|
||||
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
@@ -349,9 +362,13 @@ export class BiometricMessageHandlerService {
|
||||
});
|
||||
}
|
||||
|
||||
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
|
||||
private async secureCommunication(
|
||||
connectedApp: ConnectedApp,
|
||||
remotePublicKey: Uint8Array,
|
||||
appId: string,
|
||||
) {
|
||||
const secret = await this.cryptoFunctionService.randomBytes(64);
|
||||
const connectedApp = await this.connectedApps.get(appId);
|
||||
|
||||
connectedApp.sessionSecret = new SymmetricCryptoKey(secret).keyB64;
|
||||
await this.connectedApps.set(appId, connectedApp);
|
||||
|
||||
@@ -421,11 +438,15 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** A process reload after a biometric unlock should happen if the userkey that was used for biometric unlock is for a different user than the
|
||||
/**
|
||||
* A process reload after a biometric unlock should happen if the userkey that was used for biometric unlock is for a different user than the
|
||||
* currently active account. The userkey for the active account was in memory anyways. Further, if the desktop app is locked, a reload should occur (since the userkey was not already in memory).
|
||||
*/
|
||||
async processReloadWhenRequired(messageUserId: UserId) {
|
||||
const currentlyActiveAccountId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const currentlyActiveAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (currentlyActiveAccountId == null) {
|
||||
return;
|
||||
}
|
||||
const isCurrentlyActiveAccountUnlocked =
|
||||
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
|
||||
AuthenticationStatus.Unlocked;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { ReplaySubject, combineLatest, map } from "rxjs";
|
||||
import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||
import { UserId } from "../src/types/guid";
|
||||
@@ -55,7 +55,7 @@ export class FakeAccountService implements AccountService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
get nextUpAccount$() {
|
||||
get nextUpAccount$(): Observable<Account> {
|
||||
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
|
||||
map(([accounts, activeAccount, sortedUserIds]) => {
|
||||
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
|
||||
|
||||
@@ -225,9 +225,9 @@ export class FakeStateProvider implements StateProvider {
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]> {
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
|
||||
@@ -131,9 +131,9 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
@@ -206,9 +206,9 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T]> {
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
|
||||
@@ -18,13 +18,13 @@ export interface GlobalState<T> {
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
update: <TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T>;
|
||||
) => Promise<T | null>;
|
||||
|
||||
/**
|
||||
* An observable stream of this state, the first emission of this will be the current state on disk
|
||||
* and subsequent updates will be from an update to that state.
|
||||
*/
|
||||
state$: Observable<T>;
|
||||
state$: Observable<T | null>;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class DefaultSingleUserState<T>
|
||||
extends StateBase<T, UserKeyDefinition<T>>
|
||||
implements SingleUserState<T>
|
||||
{
|
||||
readonly combinedState$: Observable<CombinedState<T>>;
|
||||
readonly combinedState$: Observable<CombinedState<T | null>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
|
||||
@@ -54,9 +54,9 @@ export class DefaultStateProvider implements StateProvider {
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]> {
|
||||
): Promise<[UserId, T | null]> {
|
||||
if (userId) {
|
||||
return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
defer,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
merge,
|
||||
Observable,
|
||||
ReplaySubject,
|
||||
share,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { DebugOptions } from "../key-definition";
|
||||
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
|
||||
import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
|
||||
|
||||
import { getStoredValue } from "./util";
|
||||
|
||||
@@ -36,7 +36,7 @@ type KeyDefinitionRequirements<T> = {
|
||||
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
|
||||
private updatePromise: Promise<T>;
|
||||
|
||||
readonly state$: Observable<T>;
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
constructor(
|
||||
protected readonly key: StorageKey,
|
||||
@@ -86,9 +86,9 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options: StateUpdateOptions<T, TCombine> = {},
|
||||
): Promise<T> {
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.updatePromise != null) {
|
||||
await this.updatePromise;
|
||||
@@ -96,17 +96,16 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
|
||||
|
||||
try {
|
||||
this.updatePromise = this.internalUpdate(configureState, options);
|
||||
const newState = await this.updatePromise;
|
||||
return newState;
|
||||
return await this.updatePromise;
|
||||
} finally {
|
||||
this.updatePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async internalUpdate<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
): Promise<T | null> {
|
||||
const currentState = await this.getStateForUpdate();
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
@@ -122,7 +121,7 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
|
||||
return newState;
|
||||
}
|
||||
|
||||
protected async doStorageSave(newState: T, oldState: T) {
|
||||
protected async doStorageSave(newState: T | null, oldState: T) {
|
||||
if (this.keyDefinition.debug.enableUpdateLogging) {
|
||||
this.logService.info(
|
||||
`Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`,
|
||||
|
||||
@@ -60,9 +60,9 @@ export abstract class StateProvider {
|
||||
*/
|
||||
abstract setUserState<T>(
|
||||
keyDefinition: UserKeyDefinition<T>,
|
||||
value: T,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T]>;
|
||||
): Promise<[UserId, T | null]>;
|
||||
|
||||
/** @see{@link ActiveUserStateProvider.get} */
|
||||
abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface UserState<T> {
|
||||
readonly state$: Observable<T | null>;
|
||||
|
||||
/** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */
|
||||
readonly combinedState$: Observable<CombinedState<T>>;
|
||||
readonly combinedState$: Observable<CombinedState<T | null>>;
|
||||
}
|
||||
|
||||
export const activeMarker: unique symbol = Symbol("active");
|
||||
@@ -38,9 +38,9 @@ export interface ActiveUserState<T> extends UserState<T> {
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<[UserId, T]>;
|
||||
) => Promise<[UserId, T | null]>;
|
||||
}
|
||||
|
||||
export interface SingleUserState<T> extends UserState<T> {
|
||||
@@ -58,7 +58,7 @@ export interface SingleUserState<T> extends UserState<T> {
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
configureState: (state: T | null, dependencies: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
) => Promise<T>;
|
||||
) => Promise<T | null>;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ export class DefaultThemeStateService implements ThemeStateService {
|
||||
map(([theme, isExtensionRefresh]) => {
|
||||
// The extension refresh should not allow for Nord or SolarizedDark
|
||||
// Default the user to their system theme
|
||||
if (isExtensionRefresh && [ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)) {
|
||||
if (
|
||||
isExtensionRefresh &&
|
||||
theme != null &&
|
||||
[ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)
|
||||
) {
|
||||
return ThemeType.System;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export type SimpleDialogOptions = {
|
||||
* If null is provided, the cancel button will be removed.
|
||||
*
|
||||
* If not localized, pass in a `Translation` */
|
||||
cancelButtonText?: string | Translation;
|
||||
cancelButtonText?: string | Translation | null;
|
||||
|
||||
/** Whether or not the user can use escape or clicking the backdrop to close the dialog */
|
||||
disableClose?: boolean;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { makeEncString, trackEmissions } from "../../../common/spec";
|
||||
import {
|
||||
makeEncString,
|
||||
trackEmissions,
|
||||
FakeStateProvider,
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../common/spec/fake-account-service";
|
||||
import { FakeGlobalState, FakeSingleUserState } from "../../../common/spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../common/spec/fake-state-provider";
|
||||
} from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
|
||||
import {
|
||||
@@ -51,7 +52,7 @@ describe("BiometricStateService", () => {
|
||||
|
||||
it("emits false when the require password on start state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START);
|
||||
state.nextState(undefined);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
|
||||
});
|
||||
@@ -60,14 +61,14 @@ describe("BiometricStateService", () => {
|
||||
describe("encryptedClientKeyHalf$", () => {
|
||||
it("emits when the encryptedClientKeyHalf state changes", async () => {
|
||||
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
state.nextState(encryptedClientKeyHalf);
|
||||
state.nextState(encryptedClientKeyHalf as unknown as EncryptedString);
|
||||
|
||||
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
|
||||
});
|
||||
|
||||
it("emits false when the encryptedClientKeyHalf state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
state.nextState(undefined);
|
||||
state.nextState(undefined as unknown as EncryptedString);
|
||||
|
||||
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null);
|
||||
});
|
||||
@@ -76,7 +77,7 @@ describe("BiometricStateService", () => {
|
||||
describe("fingerprintValidated$", () => {
|
||||
it("emits when the fingerprint validated state changes", async () => {
|
||||
const state = stateProvider.global.getFake(FINGERPRINT_VALIDATED);
|
||||
state.stateSubject.next(undefined);
|
||||
state.stateSubject.next(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(false);
|
||||
|
||||
@@ -172,7 +173,7 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
|
||||
it("throws when called with no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
await accountService.switchAccount(null as unknown as UserId);
|
||||
await expect(sut.setUserPromptCancelled()).rejects.toThrow(
|
||||
"Cannot update biometric prompt cancelled state without an active user",
|
||||
);
|
||||
@@ -261,7 +262,7 @@ describe("BiometricStateService", () => {
|
||||
|
||||
it("emits false when biometricUnlockEnabled state is undefined", async () => {
|
||||
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED);
|
||||
state.nextState(undefined);
|
||||
state.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false);
|
||||
});
|
||||
@@ -291,7 +292,9 @@ describe("BiometricStateService", () => {
|
||||
});
|
||||
|
||||
it("returns false when the state is not set", async () => {
|
||||
stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(undefined);
|
||||
stateProvider.singleUser
|
||||
.getFake(userId, BIOMETRIC_UNLOCK_ENABLED)
|
||||
.nextState(undefined as unknown as boolean);
|
||||
|
||||
expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EncryptedString, EncString } from "../../../common/src/platform/models/domain/enc-string";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "../../../common/src/platform/state";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserId } from "../../../common/src/types/guid";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
BIOMETRIC_UNLOCK_ENABLED,
|
||||
@@ -34,7 +26,7 @@ export abstract class BiometricStateService {
|
||||
*
|
||||
* Tracks the currently active user
|
||||
*/
|
||||
abstract encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
abstract encryptedClientKeyHalf$: Observable<EncString | null>;
|
||||
/**
|
||||
* whether or not a password is required on first unlock after opening the application
|
||||
*
|
||||
@@ -71,42 +63,54 @@ export abstract class BiometricStateService {
|
||||
* @param value whether or not a password is required on first unlock after opening the application
|
||||
*/
|
||||
abstract setRequirePasswordOnStart(value: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the biometric unlock enabled state for the currently active user.
|
||||
* @param enabled whether or not to store a biometric key to unlock the vault
|
||||
*/
|
||||
abstract setBiometricUnlockEnabled(enabled: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Gets the biometric unlock enabled state for the given user.
|
||||
* @param userId user Id to check
|
||||
*/
|
||||
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
|
||||
|
||||
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void>;
|
||||
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
|
||||
|
||||
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString | null>;
|
||||
|
||||
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
|
||||
|
||||
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the active user's state to reflect that they've been warned about requiring password on start.
|
||||
*/
|
||||
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the active user's state to reflect that they've cancelled the biometric prompt.
|
||||
*/
|
||||
abstract setUserPromptCancelled(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resets the given user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
* @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used.
|
||||
*/
|
||||
abstract resetUserPromptCancelled(userId?: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resets all user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
*/
|
||||
abstract resetAllPromptCancelled(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
|
||||
* @param prompt Whether or not to prompt for biometrics on application start.
|
||||
*/
|
||||
abstract setPromptAutomatically(prompt: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates whether or not IPC has been validated by the user this session
|
||||
* @param validated the value to save
|
||||
@@ -115,7 +119,7 @@ export abstract class BiometricStateService {
|
||||
|
||||
abstract updateLastProcessReload(): Promise<void>;
|
||||
|
||||
abstract getLastProcessReload(): Promise<Date>;
|
||||
abstract getLastProcessReload(): Promise<Date | null>;
|
||||
|
||||
abstract logout(userId: UserId): Promise<void>;
|
||||
}
|
||||
@@ -123,20 +127,20 @@ export abstract class BiometricStateService {
|
||||
export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private biometricUnlockEnabledState: ActiveUserState<boolean>;
|
||||
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString>;
|
||||
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
||||
private promptCancelledState: GlobalState<Record<UserId, boolean>>;
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
private fingerprintValidatedState: GlobalState<boolean>;
|
||||
private lastProcessReloadState: GlobalState<Date>;
|
||||
biometricUnlockEnabled$: Observable<boolean>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | null>;
|
||||
requirePasswordOnStart$: Observable<boolean>;
|
||||
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
promptCancelled$: Observable<boolean>;
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
fingerprintValidated$: Observable<boolean>;
|
||||
lastProcessReload$: Observable<Date>;
|
||||
lastProcessReload$: Observable<Date | null>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
|
||||
@@ -164,7 +168,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
this.promptCancelledState.state$,
|
||||
]).pipe(
|
||||
map(([userId, record]) => {
|
||||
return record?.[userId] ?? false;
|
||||
return userId != null ? (record?.[userId] ?? false) : false;
|
||||
}),
|
||||
);
|
||||
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
||||
@@ -188,7 +192,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
}
|
||||
|
||||
async setRequirePasswordOnStart(value: boolean): Promise<void> {
|
||||
let currentActiveId: UserId;
|
||||
let currentActiveId: UserId | undefined = undefined;
|
||||
await this.requirePasswordOnStartState.update(
|
||||
(_, [userId]) => {
|
||||
currentActiveId = userId;
|
||||
@@ -198,7 +202,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
combineLatestWith: this.requirePasswordOnStartState.combinedState$,
|
||||
},
|
||||
);
|
||||
if (!value) {
|
||||
if (!value && currentActiveId) {
|
||||
await this.removeEncryptedClientKeyHalf(currentActiveId);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +226,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
));
|
||||
}
|
||||
|
||||
async getEncryptedClientKeyHalf(userId: UserId): Promise<EncString> {
|
||||
async getEncryptedClientKeyHalf(userId: UserId): Promise<EncString | null> {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider
|
||||
.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF)
|
||||
@@ -244,7 +248,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
async resetUserPromptCancelled(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getGlobal(PROMPT_CANCELLED).update(
|
||||
(data, activeUserId) => {
|
||||
if (data != null) {
|
||||
delete data[userId ?? activeUserId];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
{
|
||||
@@ -257,8 +263,10 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
async setUserPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(
|
||||
(record, userId) => {
|
||||
if (userId != null) {
|
||||
record ??= {};
|
||||
record[userId] = true;
|
||||
}
|
||||
return record;
|
||||
},
|
||||
{
|
||||
@@ -291,13 +299,13 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
await this.lastProcessReloadState.update(() => new Date());
|
||||
}
|
||||
|
||||
async getLastProcessReload(): Promise<Date> {
|
||||
async getLastProcessReload(): Promise<Date | null> {
|
||||
return await firstValueFrom(this.lastProcessReload$);
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedClientKeyHalfToEncString(
|
||||
encryptedKeyHalf: EncryptedString | undefined,
|
||||
): EncString {
|
||||
encryptedKeyHalf: EncryptedString | null | undefined,
|
||||
): EncString | null {
|
||||
return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { KeyDefinition, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import {
|
||||
@@ -21,12 +20,7 @@ describe.each([
|
||||
[FINGERPRINT_VALIDATED, true],
|
||||
])(
|
||||
"deserializes state %s",
|
||||
(
|
||||
...args:
|
||||
| [UserKeyDefinition<EncryptedString>, EncryptedString]
|
||||
| [UserKeyDefinition<boolean>, boolean]
|
||||
| [KeyDefinition<boolean>, boolean]
|
||||
) => {
|
||||
(...args: [UserKeyDefinition<unknown> | KeyDefinition<unknown>, unknown]) => {
|
||||
function testDeserialization<T>(
|
||||
keyDefinition: UserKeyDefinition<T> | KeyDefinition<T>,
|
||||
state: T,
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EncryptedString } from "../../../common/src/platform/models/domain/enc-string";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import {
|
||||
KeyDefinition,
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
UserKeyDefinition,
|
||||
} from "../../../common/src/platform/state";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserId } from "../../../common/src/types/guid";
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* Indicates whether the user elected to store a biometric key to unlock their vault.
|
||||
|
||||
@@ -87,7 +87,10 @@ export class DefaultTaskService implements TaskService {
|
||||
* @param tasks
|
||||
* @private
|
||||
*/
|
||||
private updateTaskState(userId: UserId, tasks: SecurityTaskData[]): Promise<SecurityTaskData[]> {
|
||||
private updateTaskState(
|
||||
userId: UserId,
|
||||
tasks: SecurityTaskData[],
|
||||
): Promise<SecurityTaskData[] | null> {
|
||||
return this.taskState(userId).update(() => tasks);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user