1
0
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:
Maciej Zieniuk
2025-02-10 13:31:19 +01:00
committed by GitHub
parent 40e8c88d77
commit 7e2e604439
23 changed files with 307 additions and 204 deletions

View File

@@ -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 { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -57,26 +55,29 @@ type ReceiveMessageOuter = {
messageId?: number; messageId?: number;
// Should only have one of these. // Should only have one of these.
message?: EncString; message?: ReceiveMessage | EncString;
sharedSecret?: string; sharedSecret?: string;
}; };
type Callback = { type Callback = {
resolver: any; resolver: (value?: unknown) => void;
rejecter: any; rejecter: (reason?: any) => void;
};
type SecureChannel = {
privateKey: Uint8Array;
publicKey: Uint8Array;
sharedSecret?: SymmetricCryptoKey;
setupResolve: (value?: unknown) => void;
}; };
export class NativeMessagingBackground { export class NativeMessagingBackground {
connected = false; connected = false;
private connecting: boolean; private connecting: boolean = false;
private port: browser.runtime.Port | chrome.runtime.Port; private port?: browser.runtime.Port | chrome.runtime.Port;
private appId?: string;
private privateKey: Uint8Array = null; private secureChannel?: SecureChannel;
private publicKey: Uint8Array = null;
private secureSetupResolve: any = null;
private sharedSecret: SymmetricCryptoKey;
private appId: string;
private validatingFingerprint: boolean;
private messageId = 0; private messageId = 0;
private callbacks = new Map<number, Callback>(); private callbacks = new Map<number, Callback>();
@@ -108,11 +109,13 @@ export class NativeMessagingBackground {
async connect() { async connect() {
this.logService.info("[Native Messaging IPC] Connecting to Bitwarden Desktop app..."); 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); await this.biometricStateService.setFingerprintValidated(false);
return new Promise<void>((resolve, reject) => { 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; this.connecting = true;
@@ -131,7 +134,8 @@ export class NativeMessagingBackground {
connectedCallback(); connectedCallback();
} }
this.port.onMessage.addListener(async (message: ReceiveMessageOuter) => { port.onMessage.addListener(async (messageRaw: unknown) => {
const message = messageRaw as ReceiveMessageOuter;
switch (message.command) { switch (message.command) {
case "connected": case "connected":
connectedCallback(); connectedCallback();
@@ -142,7 +146,7 @@ export class NativeMessagingBackground {
reject(new Error("startDesktop")); reject(new Error("startDesktop"));
} }
this.connected = false; this.connected = false;
this.port.disconnect(); port.disconnect();
// reject all // reject all
for (const callback of this.callbacks.values()) { for (const callback of this.callbacks.values()) {
callback.rejecter("disconnected"); callback.rejecter("disconnected");
@@ -151,18 +155,31 @@ export class NativeMessagingBackground {
break; break;
case "setupEncryption": { case "setupEncryption": {
// Ignore since it belongs to another device // 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; return;
} }
const encrypted = Utils.fromB64ToArray(message.sharedSecret); const encrypted = Utils.fromB64ToArray(message.sharedSecret);
const decrypted = await this.cryptoFunctionService.rsaDecrypt( const decrypted = await this.cryptoFunctionService.rsaDecrypt(
encrypted, encrypted,
this.privateKey, this.secureChannel.privateKey,
HashAlgorithmForEncryption, HashAlgorithmForEncryption,
); );
this.sharedSecret = new SymmetricCryptoKey(decrypted); this.secureChannel.sharedSecret = new SymmetricCryptoKey(decrypted);
this.logService.info("[Native Messaging IPC] Secure channel established"); this.logService.info("[Native Messaging IPC] Secure channel established");
if ("messageId" in message) { if ("messageId" in message) {
@@ -173,26 +190,27 @@ export class NativeMessagingBackground {
this.isConnectedToOutdatedDesktopClient = true; this.isConnectedToOutdatedDesktopClient = true;
} }
this.secureSetupResolve(); this.secureChannel.setupResolve();
break; break;
} }
case "invalidateEncryption": case "invalidateEncryption":
// Ignore since it belongs to another device // Ignore since it belongs to another device
if (message.appId !== this.appId) { if (message.appId !== appId) {
return; return;
} }
this.logService.warning( this.logService.warning(
"[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...", "[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...",
); );
this.sharedSecret = null; this.secureChannel = undefined;
this.privateKey = null;
this.connected = false; this.connected = false;
if (this.callbacks.has(message.messageId)) { if (message.messageId != null) {
this.callbacks.get(message.messageId).rejecter({ if (this.callbacks.has(message.messageId)) {
message: "invalidateEncryption", this.callbacks.get(message.messageId)?.rejecter({
}); message: "invalidateEncryption",
});
}
} }
return; return;
case "verifyFingerprint": { case "verifyFingerprint": {
@@ -217,21 +235,25 @@ export class NativeMessagingBackground {
break; break;
} }
case "wrongUserId": case "wrongUserId":
if (this.callbacks.has(message.messageId)) { if (message.messageId != null) {
this.callbacks.get(message.messageId).rejecter({ if (this.callbacks.has(message.messageId)) {
message: "wrongUserId", this.callbacks.get(message.messageId)?.rejecter({
}); message: "wrongUserId",
});
}
} }
return; return;
default: default:
// Ignore since it belongs to another device // Ignore since it belongs to another device
if (!this.platformUtilsService.isSafari() && message.appId !== this.appId) { if (!this.platformUtilsService.isSafari() && message.appId !== appId) {
return; return;
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. if (message.message != null) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
this.onMessage(message.message); // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onMessage(message.message);
}
} }
}); });
@@ -240,16 +262,15 @@ export class NativeMessagingBackground {
if (BrowserApi.isWebExtensionsApi) { if (BrowserApi.isWebExtensionsApi) {
error = p.error.message; error = p.error.message;
} else { } else {
error = chrome.runtime.lastError.message; error = chrome.runtime.lastError?.message;
} }
this.sharedSecret = null; this.secureChannel = undefined;
this.privateKey = null;
this.connected = false; this.connected = false;
this.logService.error("NativeMessaging port disconnected because of error: " + error); 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)); reject(new Error(reason));
}); });
}); });
@@ -293,13 +314,13 @@ export class NativeMessagingBackground {
); );
const callback = this.callbacks.get(messageId); const callback = this.callbacks.get(messageId);
this.callbacks.delete(messageId); this.callbacks.delete(messageId);
callback.rejecter("errorConnecting"); callback?.rejecter("errorConnecting");
} }
setTimeout(() => { setTimeout(() => {
if (this.callbacks.has(messageId)) { if (this.callbacks.has(messageId)) {
this.logService.info("[Native Messaging IPC] Message timed out and received no response"); 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", message: "timeout",
}); });
this.callbacks.delete(messageId); this.callbacks.delete(messageId);
@@ -320,16 +341,19 @@ export class NativeMessagingBackground {
if (this.platformUtilsService.isSafari()) { if (this.platformUtilsService.isSafari()) {
this.postMessage(message as any); this.postMessage(message as any);
} else { } 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) { async encryptMessage(message: Message) {
if (this.sharedSecret == null) { if (this.secureChannel?.sharedSecret == null) {
await this.secureCommunication(); 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) { private postMessage(message: OuterMessage, messageId?: number) {
@@ -346,7 +370,7 @@ export class NativeMessagingBackground {
mac: message.message.mac, mac: message.message.mac,
}; };
} }
this.port.postMessage(msg); this.port!.postMessage(msg);
// FIXME: Remove when updating file. Eslint update // FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) { } catch (e) {
@@ -354,26 +378,30 @@ export class NativeMessagingBackground {
"[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.", "[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.",
); );
this.sharedSecret = null; this.secureChannel = undefined;
this.privateKey = null;
this.connected = false; this.connected = false;
if (this.callbacks.has(messageId)) { if (messageId != null && this.callbacks.has(messageId)) {
this.callbacks.get(messageId).rejecter("invalidateEncryption"); this.callbacks.get(messageId)!.rejecter("invalidateEncryption");
} }
} }
} }
private async onMessage(rawMessage: ReceiveMessage | EncString) { private async onMessage(rawMessage: ReceiveMessage | EncString) {
let message = rawMessage as ReceiveMessage; let message: ReceiveMessage;
if (!this.platformUtilsService.isSafari()) { if (!this.platformUtilsService.isSafari()) {
if (this.secureChannel?.sharedSecret == null) {
return;
}
message = JSON.parse( message = JSON.parse(
await this.encryptService.decryptToUtf8( await this.encryptService.decryptToUtf8(
rawMessage as EncString, rawMessage as EncString,
this.sharedSecret, this.secureChannel.sharedSecret,
"ipc-desktop-ipc-channel-key", "ipc-desktop-ipc-channel-key",
), ),
); );
} else {
message = rawMessage as ReceiveMessage;
} }
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) { if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
@@ -390,15 +418,17 @@ export class NativeMessagingBackground {
this.logService.info( this.logService.info(
`[Native Messaging IPC] Received legacy message of type ${message.command}`, `[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;
const resolver = this.callbacks.get(messageId); if (messageId != null) {
this.callbacks.delete(messageId); const resolver = this.callbacks.get(messageId);
resolver.resolver(message); this.callbacks.delete(messageId);
resolver!.resolver(message);
}
return; return;
} }
if (this.callbacks.has(messageId)) { if (this.callbacks.has(messageId)) {
this.callbacks.get(messageId).resolver(message); this.callbacks.get(messageId)!.resolver(message);
} else { } else {
this.logService.info("[Native Messaging IPC] Received message without a callback", message); this.logService.info("[Native Messaging IPC] Received message without a callback", message);
} }
@@ -406,8 +436,6 @@ export class NativeMessagingBackground {
private async secureCommunication() { private async secureCommunication() {
const [publicKey, privateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); const [publicKey, privateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.publicKey = publicKey;
this.privateKey = privateKey;
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; 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. // 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++, 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) { private async sendUnencrypted(message: Message) {
@@ -429,11 +463,17 @@ export class NativeMessagingBackground {
message.timestamp = Date.now(); message.timestamp = Date.now();
this.postMessage({ appId: this.appId, message: message }); this.postMessage({ appId: this.appId!, message: message });
} }
private async showFingerprintDialog() { 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", { this.messagingService.send("showNativeMessagingFingerprintDialog", {
fingerprint: fingerprint, fingerprint: fingerprint,

View File

@@ -32,6 +32,9 @@ export class MainBiometricsIPCListener {
case BiometricAction.GetStatusForUser: case BiometricAction.GetStatusForUser:
return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId); return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId);
case BiometricAction.SetKeyForUser: case BiometricAction.SetKeyForUser:
if (message.key == null) {
return;
}
return await this.biometricService.setBiometricProtectedUnlockKeyForUser( return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
message.userId as UserId, message.userId as UserId,
message.key, message.key,
@@ -41,6 +44,9 @@ export class MainBiometricsIPCListener {
message.userId as UserId, message.userId as UserId,
); );
case BiometricAction.SetClientKeyHalf: case BiometricAction.SetClientKeyHalf:
if (message.key == null) {
return;
}
return await this.biometricService.setClientKeyHalfForUser( return await this.biometricService.setClientKeyHalfForUser(
message.userId as UserId, message.userId as UserId,
message.key, message.key,

View File

@@ -25,10 +25,6 @@ export class MainBiometricsService extends DesktopBiometricsService {
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
) { ) {
super(); super();
this.loadOsBiometricService(this.platform);
}
private loadOsBiometricService(platform: NodeJS.Platform) {
if (platform === "win32") { if (platform === "win32") {
// eslint-disable-next-line // eslint-disable-next-line
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default; const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
@@ -117,13 +113,16 @@ export class MainBiometricsService extends DesktopBiometricsService {
} }
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> { async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
return SymmetricCryptoKey.fromString( const biometricKey = await this.osBiometricsService.getBiometricKey(
await this.osBiometricsService.getBiometricKey( "Bitwarden_biometric",
"Bitwarden_biometric", `${userId}_user_biometric`,
`${userId}_user_biometric`, this.clientKeyHalves.get(userId),
this.clientKeyHalves.get(userId), );
), if (biometricKey == null) {
) as UserKey; return null;
}
return SymmetricCryptoKey.fromString(biometricKey) as UserKey;
} }
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> { async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {

View File

@@ -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 { spawn } from "child_process";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 // 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. // when we want to force a re-derive of the key material.
private setIv(iv: string) { private setIv(iv?: string) {
this._iv = iv; this._iv = iv ?? null;
this._osKeyHalf = null; this._osKeyHalf = null;
} }
private async getStorageDetails({ private async getStorageDetails({
clientKeyHalfB64, clientKeyHalfB64,
}: { }: {
clientKeyHalfB64: string; clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) { if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); 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._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64; this._iv = keyMaterial.ivB64;
} }
if (this._iv == null) {
throw new Error("Initialization Vector is null");
}
return { return {
key_material: { key_material: {
osKeyPartB64: this._osKeyHalf, osKeyPartB64: this._osKeyHalf,

View File

@@ -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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -104,7 +102,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
private async getStorageDetails({ private async getStorageDetails({
clientKeyHalfB64, clientKeyHalfB64,
}: { }: {
clientKeyHalfB64: string; clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) { if (this._osKeyHalf == null) {
// Prompts Windows Hello // Prompts Windows Hello
@@ -113,6 +111,10 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
this._iv = keyMaterial.ivB64; this._iv = keyMaterial.ivB64;
} }
if (this._iv == null) {
throw new Error("Initialization Vector is null");
}
const result = { const result = {
key_material: { key_material: {
osKeyPartB64: this._osKeyHalf, 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 // 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. // when we want to force a re-derive of the key material.
private setIv(iv: string) { private setIv(iv?: string) {
this._iv = iv; this._iv = iv ?? null;
this._osKeyHalf = null; this._osKeyHalf = null;
} }
@@ -149,9 +151,9 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
encryptedValue: EncString, encryptedValue: EncString,
service: string, service: string,
storageKey: string, storageKey: string,
clientKeyPartB64: string, clientKeyPartB64: string | undefined,
) { ) {
if (encryptedValue.iv == null || encryptedValue == null) { if (encryptedValue.iv == null) {
return; return;
} }
@@ -183,7 +185,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
storageKey, storageKey,
}: { }: {
value: SymmetricCryptoKey; value: SymmetricCryptoKey;
clientKeyPartB64: string; clientKeyPartB64: string | undefined;
service: string; service: string;
storageKey: string; storageKey: string;
}): Promise<boolean> { }): 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 */ /** Derives a witness key from a symmetric key being stored for biometric protection */
private witnessKeyMaterial( private witnessKeyMaterial(
symmetricKey: SymmetricCryptoKey, symmetricKey: SymmetricCryptoKey,
clientKeyPartB64: string, clientKeyPartB64: string | undefined,
): biometrics.KeyMaterial { ): biometrics.KeyMaterial {
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64; const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;

View File

@@ -2,7 +2,7 @@ import { NgZone } from "@angular/core";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs"; 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; 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 SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId;
const accounts = { const accounts: Record<UserId, AccountInfo> = {
[SomeUser]: { [SomeUser]: {
name: "some user", name: "some user",
email: "some.user@example.com", email: "some.user@example.com",
@@ -108,6 +108,30 @@ describe("BiometricMessageHandlerService", () => {
}); });
describe("setup encryption", () => { 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 () => { it("should reject when user is not in app", async () => {
await service.handleMessage({ await service.handleMessage({
appId: "appId", appId: "appId",
@@ -115,6 +139,7 @@ describe("BiometricMessageHandlerService", () => {
command: "setupEncryption", command: "setupEncryption",
messageId: 0, messageId: 0,
userId: "unknownUser" as UserId, userId: "unknownUser" as UserId,
publicKey: Utils.fromUtf8ToB64("publicKey"),
}, },
}); });
expect((global as any).ipc.platform.nativeMessaging.sendMessage).toHaveBeenCalledWith({ 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 // always reload when another user is active than the requested one
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, false, true], [SomeUser, AuthenticationStatus.Unlocked, AnotherUser, false, true],
[SomeUser, AuthenticationStatus.Locked, 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 // don't reload in dev mode
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, true, false], [SomeUser, AuthenticationStatus.Unlocked, SomeUser, true, false],
[SomeUser, AuthenticationStatus.Locked, SomeUser, true, false], [SomeUser, AuthenticationStatus.Locked, SomeUser, true, false],
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, true, false], [SomeUser, AuthenticationStatus.Unlocked, AnotherUser, true, false],
[SomeUser, AuthenticationStatus.Locked, AnotherUser, true, false], [SomeUser, AuthenticationStatus.Locked, AnotherUser, true, false],
[null, AuthenticationStatus.Unlocked, AnotherUser, true, false],
]; ];
it.each(testCases)( it.each(testCases)(

View File

@@ -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 { Injectable, NgZone } from "@angular/core";
import { combineLatest, concatMap, firstValueFrom, map } from "rxjs"; import { combineLatest, concatMap, firstValueFrom, map } from "rxjs";
@@ -25,8 +23,7 @@ import {
} from "@bitwarden/key-management"; } from "@bitwarden/key-management";
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
import { LegacyMessage } from "../models/native-messaging/legacy-message"; import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging";
import { LegacyMessageWrapper } from "../models/native-messaging/legacy-message-wrapper";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
const MessageValidTimeout = 10 * 1000; const MessageValidTimeout = 10 * 1000;
@@ -34,14 +31,14 @@ const HashAlgorithmForAsymmetricEncryption = "sha1";
type ConnectedApp = { type ConnectedApp = {
publicKey: string; publicKey: string;
sessionSecret: string; sessionSecret: string | null;
trusted: boolean; trusted: boolean;
}; };
const ConnectedAppPrefix = "connectedApp_"; const ConnectedAppPrefix = "connectedApp_";
class ConnectedApps { class ConnectedApps {
async get(appId: string): Promise<ConnectedApp> { async get(appId: string): Promise<ConnectedApp | null> {
if (!(await this.has(appId))) { if (!(await this.has(appId))) {
return null; return null;
} }
@@ -112,6 +109,12 @@ export class BiometricMessageHandlerService {
// Request to setup secure encryption // Request to setup secure encryption
if ("command" in rawMessage && rawMessage.command === "setupEncryption") { 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); const remotePublicKey = Utils.fromB64ToArray(rawMessage.publicKey);
// Validate the UserId to ensure we are logged into the same account. // 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), publicKey: Utils.fromBufferToB64(remotePublicKey),
sessionSecret: null, sessionSecret: null,
trusted: false, trusted: false,
}); } as ConnectedApp;
await this.secureCommunication(remotePublicKey, appId); await this.connectedApps.set(appId, connectedApp);
await this.secureCommunication(connectedApp, remotePublicKey, appId);
return; return;
} }
if ((await this.connectedApps.get(appId))?.sessionSecret == null) { const sessionSecret = (await this.connectedApps.get(appId))?.sessionSecret;
if (sessionSecret == null) {
this.logService.info( this.logService.info(
"[Native Messaging IPC] Session secret for secure channel is missing. Invalidating encryption...", "[Native Messaging IPC] Session secret for secure channel is missing. Invalidating encryption...",
); );
@@ -157,7 +162,7 @@ export class BiometricMessageHandlerService {
const message: LegacyMessage = JSON.parse( const message: LegacyMessage = JSON.parse(
await this.encryptService.decryptToUtf8( await this.encryptService.decryptToUtf8(
rawMessage as EncString, rawMessage as EncString,
SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret), SymmetricCryptoKey.fromString(sessionSecret),
), ),
); );
@@ -173,7 +178,10 @@ export class BiometricMessageHandlerService {
return; 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."); this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
return; return;
} }
@@ -277,11 +285,11 @@ export class BiometricMessageHandlerService {
return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId); return this.send({ command: "biometricUnlock", response: "not unlocked" }, appId);
} }
const biometricUnlockPromise = const biometricUnlock =
message.userId == null message.userId == null
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) ? await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
: this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId); : await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId);
if (!(await biometricUnlockPromise)) { if (!biometricUnlock) {
await this.send({ command: "biometricUnlock", response: "not enabled" }, appId); await this.send({ command: "biometricUnlock", response: "not enabled" }, appId);
return this.ngZone.run(() => return this.ngZone.run(() =>
@@ -310,13 +318,13 @@ export class BiometricMessageHandlerService {
const currentlyActiveAccountId = ( const currentlyActiveAccountId = (
await firstValueFrom(this.accountService.activeAccount$) await firstValueFrom(this.accountService.activeAccount$)
).id; )?.id;
const isCurrentlyActiveAccountUnlocked = const isCurrentlyActiveAccountUnlocked =
(await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked; (await this.authService.getAuthStatus(userId)) == AuthenticationStatus.Unlocked;
// prevent proc reloading an active account, when it is the same as the browser // prevent proc reloading an active account, when it is the same as the browser
if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) { if (currentlyActiveAccountId != message.userId || !isCurrentlyActiveAccountUnlocked) {
await ipc.platform.reloadProcess(); ipc.platform.reloadProcess();
} }
} else { } else {
await this.send({ command: "biometricUnlock", response: "canceled" }, appId); await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
@@ -337,9 +345,14 @@ export class BiometricMessageHandlerService {
private async send(message: any, appId: string) { private async send(message: any, appId: string) {
message.timestamp = Date.now(); 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( const encrypted = await this.encryptService.encrypt(
JSON.stringify(message), JSON.stringify(message),
SymmetricCryptoKey.fromString((await this.connectedApps.get(appId)).sessionSecret), SymmetricCryptoKey.fromString(sessionSecret),
); );
ipc.platform.nativeMessaging.sendMessage({ 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 secret = await this.cryptoFunctionService.randomBytes(64);
const connectedApp = await this.connectedApps.get(appId);
connectedApp.sessionSecret = new SymmetricCryptoKey(secret).keyB64; connectedApp.sessionSecret = new SymmetricCryptoKey(secret).keyB64;
await this.connectedApps.set(appId, connectedApp); 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). * 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) { 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 = const isCurrentlyActiveAccountUnlocked =
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) == (await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
AuthenticationStatus.Unlocked; AuthenticationStatus.Unlocked;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { mock } from "jest-mock-extended"; 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 { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid"; 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( return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => { map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null); const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);

View File

@@ -225,9 +225,9 @@ export class FakeStateProvider implements StateProvider {
async setUserState<T>( async setUserState<T>(
userKeyDefinition: UserKeyDefinition<T>, userKeyDefinition: UserKeyDefinition<T>,
value: T, value: T | null,
userId?: UserId, userId?: UserId,
): Promise<[UserId, T]> { ): Promise<[UserId, T | null]> {
await this.mock.setUserState(userKeyDefinition, value, userId); await this.mock.setUserState(userKeyDefinition, value, userId);
if (userId) { if (userId) {
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];

View File

@@ -131,9 +131,9 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
} }
async update<TCombine>( async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
): Promise<T> { ): Promise<T | null> {
options = populateOptionsWithDefault(options); options = populateOptionsWithDefault(options);
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
const combinedDependencies = const combinedDependencies =
@@ -206,9 +206,9 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
} }
async update<TCombine>( async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
): Promise<[UserId, T]> { ): Promise<[UserId, T | null]> {
options = populateOptionsWithDefault(options); options = populateOptionsWithDefault(options);
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
const combinedDependencies = const combinedDependencies =

View File

@@ -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. * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/ */
update: <TCombine>( update: <TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T | null, dependency: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>, 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 * 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. * and subsequent updates will be from an update to that state.
*/ */
state$: Observable<T>; state$: Observable<T | null>;
} }

View File

@@ -16,7 +16,7 @@ export class DefaultSingleUserState<T>
extends StateBase<T, UserKeyDefinition<T>> extends StateBase<T, UserKeyDefinition<T>>
implements SingleUserState<T> implements SingleUserState<T>
{ {
readonly combinedState$: Observable<CombinedState<T>>; readonly combinedState$: Observable<CombinedState<T | null>>;
constructor( constructor(
readonly userId: UserId, readonly userId: UserId,

View File

@@ -54,9 +54,9 @@ export class DefaultStateProvider implements StateProvider {
async setUserState<T>( async setUserState<T>(
userKeyDefinition: UserKeyDefinition<T>, userKeyDefinition: UserKeyDefinition<T>,
value: T, value: T | null,
userId?: UserId, userId?: UserId,
): Promise<[UserId, T]> { ): Promise<[UserId, T | null]> {
if (userId) { if (userId) {
return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)]; return [userId, await this.getUser<T>(userId, userKeyDefinition).update(() => value)];
} else { } else {

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { import {
Observable,
ReplaySubject,
defer, defer,
filter, filter,
firstValueFrom, firstValueFrom,
merge, merge,
Observable,
ReplaySubject,
share, share,
switchMap, switchMap,
tap, tap,
@@ -22,7 +22,7 @@ import {
ObservableStorageService, ObservableStorageService,
} from "../../abstractions/storage.service"; } from "../../abstractions/storage.service";
import { DebugOptions } from "../key-definition"; import { DebugOptions } from "../key-definition";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options";
import { getStoredValue } from "./util"; import { getStoredValue } from "./util";
@@ -36,7 +36,7 @@ type KeyDefinitionRequirements<T> = {
export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> { export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>> {
private updatePromise: Promise<T>; private updatePromise: Promise<T>;
readonly state$: Observable<T>; readonly state$: Observable<T | null>;
constructor( constructor(
protected readonly key: StorageKey, protected readonly key: StorageKey,
@@ -86,9 +86,9 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
} }
async update<TCombine>( async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine> = {}, options: StateUpdateOptions<T, TCombine> = {},
): Promise<T> { ): Promise<T | null> {
options = populateOptionsWithDefault(options); options = populateOptionsWithDefault(options);
if (this.updatePromise != null) { if (this.updatePromise != null) {
await this.updatePromise; await this.updatePromise;
@@ -96,17 +96,16 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
try { try {
this.updatePromise = this.internalUpdate(configureState, options); this.updatePromise = this.internalUpdate(configureState, options);
const newState = await this.updatePromise; return await this.updatePromise;
return newState;
} finally { } finally {
this.updatePromise = null; this.updatePromise = null;
} }
} }
private async internalUpdate<TCombine>( private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T, configureState: (state: T | null, dependency: TCombine) => T | null,
options: StateUpdateOptions<T, TCombine>, options: StateUpdateOptions<T, TCombine>,
): Promise<T> { ): Promise<T | null> {
const currentState = await this.getStateForUpdate(); const currentState = await this.getStateForUpdate();
const combinedDependencies = const combinedDependencies =
options.combineLatestWith != null options.combineLatestWith != null
@@ -122,7 +121,7 @@ export abstract class StateBase<T, KeyDef extends KeyDefinitionRequirements<T>>
return newState; return newState;
} }
protected async doStorageSave(newState: T, oldState: T) { protected async doStorageSave(newState: T | null, oldState: T) {
if (this.keyDefinition.debug.enableUpdateLogging) { if (this.keyDefinition.debug.enableUpdateLogging) {
this.logService.info( this.logService.info(
`Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`, `Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`,

View File

@@ -60,9 +60,9 @@ export abstract class StateProvider {
*/ */
abstract setUserState<T>( abstract setUserState<T>(
keyDefinition: UserKeyDefinition<T>, keyDefinition: UserKeyDefinition<T>,
value: T, value: T | null,
userId?: UserId, userId?: UserId,
): Promise<[UserId, T]>; ): Promise<[UserId, T | null]>;
/** @see{@link ActiveUserStateProvider.get} */ /** @see{@link ActiveUserStateProvider.get} */
abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>; abstract getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T>;

View File

@@ -12,7 +12,7 @@ export interface UserState<T> {
readonly state$: Observable<T | null>; 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. */ /** 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"); 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. * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/ */
readonly update: <TCombine>( readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T, configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
) => Promise<[UserId, T]>; ) => Promise<[UserId, T | null]>;
} }
export interface SingleUserState<T> extends UserState<T> { 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. * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
*/ */
readonly update: <TCombine>( readonly update: <TCombine>(
configureState: (state: T, dependencies: TCombine) => T, configureState: (state: T | null, dependencies: TCombine) => T | null,
options?: StateUpdateOptions<T, TCombine>, options?: StateUpdateOptions<T, TCombine>,
) => Promise<T>; ) => Promise<T | null>;
} }

View File

@@ -32,7 +32,11 @@ export class DefaultThemeStateService implements ThemeStateService {
map(([theme, isExtensionRefresh]) => { map(([theme, isExtensionRefresh]) => {
// The extension refresh should not allow for Nord or SolarizedDark // The extension refresh should not allow for Nord or SolarizedDark
// Default the user to their system theme // 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; return ThemeType.System;
} }

View File

@@ -46,7 +46,7 @@ export type SimpleDialogOptions = {
* If null is provided, the cancel button will be removed. * If null is provided, the cancel button will be removed.
* *
* If not localized, pass in a `Translation` */ * 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 */ /** Whether or not the user can use escape or clicking the backdrop to close the dialog */
disableClose?: boolean; disableClose?: boolean;

View File

@@ -1,15 +1,16 @@
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { makeEncString, trackEmissions } from "../../../common/spec";
import { import {
makeEncString,
trackEmissions,
FakeStateProvider,
FakeGlobalState,
FakeSingleUserState,
FakeAccountService, FakeAccountService,
mockAccountServiceWith, mockAccountServiceWith,
} from "../../../common/spec/fake-account-service"; } from "@bitwarden/common/spec";
import { FakeGlobalState, FakeSingleUserState } from "../../../common/spec/fake-state"; import { UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider } from "../../../common/spec/fake-state-provider";
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service"; import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
import { import {
@@ -51,7 +52,7 @@ describe("BiometricStateService", () => {
it("emits false when the require password on start state is undefined", async () => { it("emits false when the require password on start state is undefined", async () => {
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START); 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); expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
}); });
@@ -60,14 +61,14 @@ describe("BiometricStateService", () => {
describe("encryptedClientKeyHalf$", () => { describe("encryptedClientKeyHalf$", () => {
it("emits when the encryptedClientKeyHalf state changes", async () => { it("emits when the encryptedClientKeyHalf state changes", async () => {
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF); 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); expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
}); });
it("emits false when the encryptedClientKeyHalf state is undefined", async () => { it("emits false when the encryptedClientKeyHalf state is undefined", async () => {
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF); 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); expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null);
}); });
@@ -76,7 +77,7 @@ describe("BiometricStateService", () => {
describe("fingerprintValidated$", () => { describe("fingerprintValidated$", () => {
it("emits when the fingerprint validated state changes", async () => { it("emits when the fingerprint validated state changes", async () => {
const state = stateProvider.global.getFake(FINGERPRINT_VALIDATED); 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); expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(false);
@@ -172,7 +173,7 @@ describe("BiometricStateService", () => {
}); });
it("throws when called with no active user", async () => { 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( await expect(sut.setUserPromptCancelled()).rejects.toThrow(
"Cannot update biometric prompt cancelled state without an active user", "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 () => { it("emits false when biometricUnlockEnabled state is undefined", async () => {
const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); 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); expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false);
}); });
@@ -291,7 +292,9 @@ describe("BiometricStateService", () => {
}); });
it("returns false when the state is not set", async () => { 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); expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false);
}); });

View File

@@ -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"; import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
// FIXME: remove `src` and fix import import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
// eslint-disable-next-line no-restricted-imports import { ActiveUserState, GlobalState, StateProvider } from "@bitwarden/common/platform/state";
import { EncryptedString, EncString } from "../../../common/src/platform/models/domain/enc-string"; import { UserId } from "@bitwarden/common/types/guid";
// 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 { import {
BIOMETRIC_UNLOCK_ENABLED, BIOMETRIC_UNLOCK_ENABLED,
@@ -34,7 +26,7 @@ export abstract class BiometricStateService {
* *
* Tracks the currently active user * 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 * 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 * @param value whether or not a password is required on first unlock after opening the application
*/ */
abstract setRequirePasswordOnStart(value: boolean): Promise<void>; abstract setRequirePasswordOnStart(value: boolean): Promise<void>;
/** /**
* Updates the biometric unlock enabled state for the currently active user. * 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 * @param enabled whether or not to store a biometric key to unlock the vault
*/ */
abstract setBiometricUnlockEnabled(enabled: boolean): Promise<void>; abstract setBiometricUnlockEnabled(enabled: boolean): Promise<void>;
/** /**
* Gets the biometric unlock enabled state for the given user. * Gets the biometric unlock enabled state for the given user.
* @param userId user Id to check * @param userId user Id to check
*/ */
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>; abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void>; 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 getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>; abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
/** /**
* Updates the active user's state to reflect that they've been warned about requiring password on start. * Updates the active user's state to reflect that they've been warned about requiring password on start.
*/ */
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>; abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
/** /**
* Updates the active user's state to reflect that they've cancelled the biometric prompt. * Updates the active user's state to reflect that they've cancelled the biometric prompt.
*/ */
abstract setUserPromptCancelled(): Promise<void>; abstract setUserPromptCancelled(): Promise<void>;
/** /**
* Resets the given user's state to reflect that they haven't cancelled the biometric prompt. * 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. * @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>; abstract resetUserPromptCancelled(userId?: UserId): Promise<void>;
/** /**
* Resets all user's state to reflect that they haven't cancelled the biometric prompt. * Resets all user's state to reflect that they haven't cancelled the biometric prompt.
*/ */
abstract resetAllPromptCancelled(): Promise<void>; abstract resetAllPromptCancelled(): Promise<void>;
/** /**
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock * 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. * @param prompt Whether or not to prompt for biometrics on application start.
*/ */
abstract setPromptAutomatically(prompt: boolean): Promise<void>; abstract setPromptAutomatically(prompt: boolean): Promise<void>;
/** /**
* Updates whether or not IPC has been validated by the user this session * Updates whether or not IPC has been validated by the user this session
* @param validated the value to save * @param validated the value to save
@@ -115,7 +119,7 @@ export abstract class BiometricStateService {
abstract updateLastProcessReload(): Promise<void>; abstract updateLastProcessReload(): Promise<void>;
abstract getLastProcessReload(): Promise<Date>; abstract getLastProcessReload(): Promise<Date | null>;
abstract logout(userId: UserId): Promise<void>; abstract logout(userId: UserId): Promise<void>;
} }
@@ -123,20 +127,20 @@ export abstract class BiometricStateService {
export class DefaultBiometricStateService implements BiometricStateService { export class DefaultBiometricStateService implements BiometricStateService {
private biometricUnlockEnabledState: ActiveUserState<boolean>; private biometricUnlockEnabledState: ActiveUserState<boolean>;
private requirePasswordOnStartState: ActiveUserState<boolean>; private requirePasswordOnStartState: ActiveUserState<boolean>;
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>; private encryptedClientKeyHalfState: ActiveUserState<EncryptedString>;
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>; private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
private promptCancelledState: GlobalState<Record<UserId, boolean>>; private promptCancelledState: GlobalState<Record<UserId, boolean>>;
private promptAutomaticallyState: ActiveUserState<boolean>; private promptAutomaticallyState: ActiveUserState<boolean>;
private fingerprintValidatedState: GlobalState<boolean>; private fingerprintValidatedState: GlobalState<boolean>;
private lastProcessReloadState: GlobalState<Date>; private lastProcessReloadState: GlobalState<Date>;
biometricUnlockEnabled$: Observable<boolean>; biometricUnlockEnabled$: Observable<boolean>;
encryptedClientKeyHalf$: Observable<EncString | undefined>; encryptedClientKeyHalf$: Observable<EncString | null>;
requirePasswordOnStart$: Observable<boolean>; requirePasswordOnStart$: Observable<boolean>;
dismissedRequirePasswordOnStartCallout$: Observable<boolean>; dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
promptCancelled$: Observable<boolean>; promptCancelled$: Observable<boolean>;
promptAutomatically$: Observable<boolean>; promptAutomatically$: Observable<boolean>;
fingerprintValidated$: Observable<boolean>; fingerprintValidated$: Observable<boolean>;
lastProcessReload$: Observable<Date>; lastProcessReload$: Observable<Date | null>;
constructor(private stateProvider: StateProvider) { constructor(private stateProvider: StateProvider) {
this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED);
@@ -164,7 +168,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
this.promptCancelledState.state$, this.promptCancelledState.state$,
]).pipe( ]).pipe(
map(([userId, record]) => { map(([userId, record]) => {
return record?.[userId] ?? false; return userId != null ? (record?.[userId] ?? false) : false;
}), }),
); );
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY); this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
@@ -188,7 +192,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
} }
async setRequirePasswordOnStart(value: boolean): Promise<void> { async setRequirePasswordOnStart(value: boolean): Promise<void> {
let currentActiveId: UserId; let currentActiveId: UserId | undefined = undefined;
await this.requirePasswordOnStartState.update( await this.requirePasswordOnStartState.update(
(_, [userId]) => { (_, [userId]) => {
currentActiveId = userId; currentActiveId = userId;
@@ -198,7 +202,7 @@ export class DefaultBiometricStateService implements BiometricStateService {
combineLatestWith: this.requirePasswordOnStartState.combinedState$, combineLatestWith: this.requirePasswordOnStartState.combinedState$,
}, },
); );
if (!value) { if (!value && currentActiveId) {
await this.removeEncryptedClientKeyHalf(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( return await firstValueFrom(
this.stateProvider this.stateProvider
.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF) .getUser(userId, ENCRYPTED_CLIENT_KEY_HALF)
@@ -244,7 +248,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
async resetUserPromptCancelled(userId: UserId): Promise<void> { async resetUserPromptCancelled(userId: UserId): Promise<void> {
await this.stateProvider.getGlobal(PROMPT_CANCELLED).update( await this.stateProvider.getGlobal(PROMPT_CANCELLED).update(
(data, activeUserId) => { (data, activeUserId) => {
delete data[userId ?? activeUserId]; if (data != null) {
delete data[userId ?? activeUserId];
}
return data; return data;
}, },
{ {
@@ -257,8 +263,10 @@ export class DefaultBiometricStateService implements BiometricStateService {
async setUserPromptCancelled(): Promise<void> { async setUserPromptCancelled(): Promise<void> {
await this.promptCancelledState.update( await this.promptCancelledState.update(
(record, userId) => { (record, userId) => {
record ??= {}; if (userId != null) {
record[userId] = true; record ??= {};
record[userId] = true;
}
return record; return record;
}, },
{ {
@@ -291,13 +299,13 @@ export class DefaultBiometricStateService implements BiometricStateService {
await this.lastProcessReloadState.update(() => new Date()); await this.lastProcessReloadState.update(() => new Date());
} }
async getLastProcessReload(): Promise<Date> { async getLastProcessReload(): Promise<Date | null> {
return await firstValueFrom(this.lastProcessReload$); return await firstValueFrom(this.lastProcessReload$);
} }
} }
function encryptedClientKeyHalfToEncString( function encryptedClientKeyHalfToEncString(
encryptedKeyHalf: EncryptedString | undefined, encryptedKeyHalf: EncryptedString | null | undefined,
): EncString { ): EncString | null {
return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf); return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf);
} }

View File

@@ -1,4 +1,3 @@
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { KeyDefinition, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { KeyDefinition, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { import {
@@ -21,12 +20,7 @@ describe.each([
[FINGERPRINT_VALIDATED, true], [FINGERPRINT_VALIDATED, true],
])( ])(
"deserializes state %s", "deserializes state %s",
( (...args: [UserKeyDefinition<unknown> | KeyDefinition<unknown>, unknown]) => {
...args:
| [UserKeyDefinition<EncryptedString>, EncryptedString]
| [UserKeyDefinition<boolean>, boolean]
| [KeyDefinition<boolean>, boolean]
) => {
function testDeserialization<T>( function testDeserialization<T>(
keyDefinition: UserKeyDefinition<T> | KeyDefinition<T>, keyDefinition: UserKeyDefinition<T> | KeyDefinition<T>,
state: T, state: T,

View File

@@ -1,16 +1,10 @@
// FIXME: remove `src` and fix import import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
// 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 { import {
KeyDefinition, KeyDefinition,
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
UserKeyDefinition, UserKeyDefinition,
} from "../../../common/src/platform/state"; } from "@bitwarden/common/platform/state";
// FIXME: remove `src` and fix import import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { UserId } from "../../../common/src/types/guid";
/** /**
* Indicates whether the user elected to store a biometric key to unlock their vault. * Indicates whether the user elected to store a biometric key to unlock their vault.

View File

@@ -87,7 +87,10 @@ export class DefaultTaskService implements TaskService {
* @param tasks * @param tasks
* @private * @private
*/ */
private updateTaskState(userId: UserId, tasks: SecurityTaskData[]): Promise<SecurityTaskData[]> { private updateTaskState(
userId: UserId,
tasks: SecurityTaskData[],
): Promise<SecurityTaskData[] | null> {
return this.taskState(userId).update(() => tasks); return this.taskState(userId).update(() => tasks);
} }
} }