mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-10741] Refactor biometrics interface & add dynamic status (#10973)
This commit is contained in:
@@ -4656,6 +4656,33 @@
|
||||
"noEditPermissions": {
|
||||
"message": "You don't have permission to edit this item"
|
||||
},
|
||||
"biometricsStatusHelptextUnlockNeeded": {
|
||||
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
|
||||
},
|
||||
"biometricsStatusHelptextHardwareUnavailable": {
|
||||
"message": "Biometric unlock is currently unavailable."
|
||||
},
|
||||
"biometricsStatusHelptextAutoSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextManualSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextDesktopDisconnected": {
|
||||
"message": "Biometric unlock is unavailable because the Bitwarden desktop app is closed."
|
||||
},
|
||||
"biometricsStatusHelptextNotEnabledInDesktop": {
|
||||
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "mail@example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"biometricsStatusHelptextUnavailableReasonUnknown": {
|
||||
"message": "Biometric unlock is currently unavailable for an unknown reason."
|
||||
},
|
||||
"authenticating": {
|
||||
"message": "Authenticating"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AvatarModule, ItemModule } from "@bitwarden/components";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
|
||||
|
||||
@@ -26,6 +27,7 @@ export class AccountComponent {
|
||||
private location: Location,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {}
|
||||
|
||||
get specialAccountAddId() {
|
||||
@@ -45,6 +47,9 @@ export class AccountComponent {
|
||||
// locked or logged out account statuses are handled by background and app.component
|
||||
if (result?.status === AuthenticationStatus.Unlocked) {
|
||||
this.location.back();
|
||||
await this.biometricsService.setShouldAutopromptNow(false);
|
||||
} else {
|
||||
await this.biometricsService.setShouldAutopromptNow(true);
|
||||
}
|
||||
this.loading.emit(false);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
<h2 bitTypography="h6">{{ "unlockMethods" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-control *ngIf="supportsBiometric">
|
||||
<bit-form-control>
|
||||
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">{{
|
||||
"unlockWithBiometrics" | i18n
|
||||
}}</bit-label>
|
||||
<bit-hint *ngIf="biometricUnavailabilityReason">
|
||||
{{ biometricUnavailabilityReason }}
|
||||
</bit-hint>
|
||||
</bit-form-control>
|
||||
<bit-form-control class="tw-pl-5" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<bit-form-control class="tw-pl-5" *ngIf="this.form.value.biometric">
|
||||
<input
|
||||
bitCheckbox
|
||||
id="autoBiometricsPrompt"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -53,7 +54,12 @@ import {
|
||||
TypographyModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import {
|
||||
KeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
BiometricsStatus,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
@@ -99,7 +105,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[] = [];
|
||||
vaultTimeoutOptions: VaultTimeoutOption[] = [];
|
||||
hasVaultTimeoutPolicy = false;
|
||||
supportsBiometric: boolean;
|
||||
biometricUnavailabilityReason: string;
|
||||
showChangeMasterPass = true;
|
||||
accountSwitcherEnabled = false;
|
||||
|
||||
@@ -199,7 +205,40 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.form.patchValue(initialValues, { emitEvent: false });
|
||||
|
||||
this.supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
timer(0, 1000)
|
||||
.pipe(
|
||||
switchMap(async () => {
|
||||
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
|
||||
const biometricSettingAvailable =
|
||||
(status !== BiometricsStatus.DesktopDisconnected &&
|
||||
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) ||
|
||||
(await this.vaultTimeoutSettingsService.isBiometricLockSet());
|
||||
if (!biometricSettingAvailable) {
|
||||
this.form.controls.biometric.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.form.controls.biometric.enable({ emitEvent: false });
|
||||
}
|
||||
|
||||
if (status === BiometricsStatus.DesktopDisconnected && !biometricSettingAvailable) {
|
||||
this.biometricUnavailabilityReason = this.i18nService.t(
|
||||
"biometricsStatusHelptextDesktopDisconnected",
|
||||
);
|
||||
} else if (
|
||||
status === BiometricsStatus.NotEnabledInConnectedDesktopApp &&
|
||||
!biometricSettingAvailable
|
||||
) {
|
||||
this.biometricUnavailabilityReason = this.i18nService.t(
|
||||
"biometricsStatusHelptextNotEnabledInDesktop",
|
||||
activeAccount.email,
|
||||
);
|
||||
} else {
|
||||
this.biometricUnavailabilityReason = "";
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
this.form.controls.vaultTimeout.valueChanges
|
||||
@@ -399,7 +438,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async updateBiometric(enabled: boolean) {
|
||||
if (enabled && this.supportsBiometric) {
|
||||
if (enabled) {
|
||||
let granted;
|
||||
try {
|
||||
granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] });
|
||||
@@ -471,7 +510,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
|
||||
const biometricsPromise = async () => {
|
||||
try {
|
||||
const result = await this.biometricsService.authenticateBiometric();
|
||||
const result = await this.biometricsService.authenticateWithBiometrics();
|
||||
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
|
||||
@@ -204,6 +204,7 @@ import {
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
DefaultBiometricStateService,
|
||||
DefaultKeyService,
|
||||
DefaultKdfConfigService,
|
||||
KdfConfigService,
|
||||
KeyService as KeyServiceAbstraction,
|
||||
@@ -241,7 +242,6 @@ import AutofillService from "../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
|
||||
import { SafariApp } from "../browser/safariApp";
|
||||
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
|
||||
import { BrowserKeyService } from "../key-management/browser-key.service";
|
||||
import { BrowserApi } from "../platform/browser/browser-api";
|
||||
import { flagEnabled } from "../platform/flags";
|
||||
import { UpdateBadge } from "../platform/listeners/update-badge";
|
||||
@@ -416,6 +416,7 @@ export default class MainBackground {
|
||||
await this.refreshMenu(true);
|
||||
if (this.systemService != null) {
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.biometricsService.setShouldAutopromptNow(false);
|
||||
await this.processReloadService.startProcessReload(this.authService);
|
||||
}
|
||||
};
|
||||
@@ -633,6 +634,7 @@ export default class MainBackground {
|
||||
|
||||
this.biometricsService = new BackgroundBrowserBiometricsService(
|
||||
runtimeNativeMessagingBackground,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider);
|
||||
@@ -649,7 +651,7 @@ export default class MainBackground {
|
||||
this.stateService,
|
||||
);
|
||||
|
||||
this.keyService = new BrowserKeyService(
|
||||
this.keyService = new DefaultKeyService(
|
||||
this.pinService,
|
||||
this.masterPasswordService,
|
||||
this.keyGenerationService,
|
||||
@@ -660,8 +662,6 @@ export default class MainBackground {
|
||||
this.stateService,
|
||||
this.accountService,
|
||||
this.stateProvider,
|
||||
this.biometricStateService,
|
||||
this.biometricsService,
|
||||
this.kdfConfigService,
|
||||
);
|
||||
|
||||
@@ -857,10 +857,8 @@ export default class MainBackground {
|
||||
this.userVerificationApiService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.pinService,
|
||||
this.logService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.platformUtilsService,
|
||||
this.kdfConfigService,
|
||||
this.biometricsService,
|
||||
);
|
||||
|
||||
this.vaultFilterService = new VaultFilterService(
|
||||
@@ -890,6 +888,7 @@ export default class MainBackground {
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
this.biometricsService,
|
||||
lockedCallback,
|
||||
logoutCallback,
|
||||
);
|
||||
@@ -1081,6 +1080,7 @@ export default class MainBackground {
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.biometricStateService,
|
||||
this.accountService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
// Other fields
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
import { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs";
|
||||
|
||||
import { 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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
@@ -14,18 +13,19 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import { KeyService, BiometricStateService, BiometricsCommands } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../platform/browser/browser-api";
|
||||
|
||||
import RuntimeBackground from "./runtime.background";
|
||||
|
||||
const MessageValidTimeout = 10 * 1000;
|
||||
const MessageNoResponseTimeout = 60 * 1000;
|
||||
const HashAlgorithmForEncryption = "sha1";
|
||||
|
||||
type Message = {
|
||||
command: string;
|
||||
messageId?: number;
|
||||
|
||||
// Filled in by this service
|
||||
userId?: string;
|
||||
@@ -43,6 +43,7 @@ type OuterMessage = {
|
||||
type ReceiveMessage = {
|
||||
timestamp: number;
|
||||
command: string;
|
||||
messageId: number;
|
||||
response?: any;
|
||||
|
||||
// Unlock key
|
||||
@@ -53,19 +54,23 @@ type ReceiveMessage = {
|
||||
type ReceiveMessageOuter = {
|
||||
command: string;
|
||||
appId: string;
|
||||
messageId?: number;
|
||||
|
||||
// Should only have one of these.
|
||||
message?: EncString;
|
||||
sharedSecret?: string;
|
||||
};
|
||||
|
||||
type Callback = {
|
||||
resolver: any;
|
||||
rejecter: any;
|
||||
};
|
||||
|
||||
export class NativeMessagingBackground {
|
||||
private connected = false;
|
||||
connected = false;
|
||||
private connecting: boolean;
|
||||
private port: browser.runtime.Port | chrome.runtime.Port;
|
||||
|
||||
private resolver: any = null;
|
||||
private rejecter: any = null;
|
||||
private privateKey: Uint8Array = null;
|
||||
private publicKey: Uint8Array = null;
|
||||
private secureSetupResolve: any = null;
|
||||
@@ -73,6 +78,11 @@ export class NativeMessagingBackground {
|
||||
private appId: string;
|
||||
private validatingFingerprint: boolean;
|
||||
|
||||
private messageId = 0;
|
||||
private callbacks = new Map<number, Callback>();
|
||||
|
||||
isConnectedToOutdatedDesktopClient = true;
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
@@ -97,6 +107,7 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.logService.info("[Native Messaging IPC] Connecting to Bitwarden Desktop app...");
|
||||
this.appId = await this.appIdService.getAppId();
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
|
||||
@@ -106,6 +117,9 @@ export class NativeMessagingBackground {
|
||||
this.connecting = true;
|
||||
|
||||
const connectedCallback = () => {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
|
||||
);
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
resolve();
|
||||
@@ -123,11 +137,17 @@ export class NativeMessagingBackground {
|
||||
connectedCallback();
|
||||
break;
|
||||
case "disconnected":
|
||||
this.logService.info("[Native Messaging IPC] Disconnected from Bitwarden Desktop app.");
|
||||
if (this.connecting) {
|
||||
reject(new Error("startDesktop"));
|
||||
}
|
||||
this.connected = false;
|
||||
this.port.disconnect();
|
||||
// reject all
|
||||
for (const callback of this.callbacks.values()) {
|
||||
callback.rejecter("disconnected");
|
||||
}
|
||||
this.callbacks.clear();
|
||||
break;
|
||||
case "setupEncryption": {
|
||||
// Ignore since it belongs to another device
|
||||
@@ -147,6 +167,16 @@ export class NativeMessagingBackground {
|
||||
await this.biometricStateService.setFingerprintValidated(true);
|
||||
}
|
||||
this.sharedSecret = new SymmetricCryptoKey(decrypted);
|
||||
this.logService.info("[Native Messaging IPC] Secure channel established");
|
||||
|
||||
if ("messageId" in message) {
|
||||
this.logService.info("[Native Messaging IPC] Non-legacy desktop client");
|
||||
this.isConnectedToOutdatedDesktopClient = false;
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Legacy desktop client");
|
||||
this.isConnectedToOutdatedDesktopClient = true;
|
||||
}
|
||||
|
||||
this.secureSetupResolve();
|
||||
break;
|
||||
}
|
||||
@@ -155,17 +185,25 @@ export class NativeMessagingBackground {
|
||||
if (message.appId !== this.appId) {
|
||||
return;
|
||||
}
|
||||
this.logService.warning(
|
||||
"[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.connected = false;
|
||||
|
||||
this.rejecter({
|
||||
message: "invalidateEncryption",
|
||||
});
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
message: "invalidateEncryption",
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "verifyFingerprint": {
|
||||
if (this.sharedSecret == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Desktop app requested trust verification by fingerprint.",
|
||||
);
|
||||
this.validatingFingerprint = true;
|
||||
// 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
|
||||
@@ -174,9 +212,11 @@ export class NativeMessagingBackground {
|
||||
break;
|
||||
}
|
||||
case "wrongUserId":
|
||||
this.rejecter({
|
||||
message: "wrongUserId",
|
||||
});
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
message: "wrongUserId",
|
||||
});
|
||||
}
|
||||
return;
|
||||
default:
|
||||
// Ignore since it belongs to another device
|
||||
@@ -210,6 +250,60 @@ export class NativeMessagingBackground {
|
||||
});
|
||||
}
|
||||
|
||||
async callCommand(message: Message): Promise<any> {
|
||||
const messageId = this.messageId++;
|
||||
|
||||
if (
|
||||
message.command == BiometricsCommands.Unlock ||
|
||||
message.command == BiometricsCommands.IsAvailable
|
||||
) {
|
||||
// TODO remove after 2025.01
|
||||
// wait until there is no other callbacks, or timeout
|
||||
const call = await firstValueFrom(
|
||||
race(
|
||||
from([false]).pipe(delay(5000)),
|
||||
timer(0, 100).pipe(
|
||||
filter(() => this.callbacks.size === 0),
|
||||
map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!call) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Message of type ${message.command} did not get a response before timing out`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const callback = new Promise((resolver, rejecter) => {
|
||||
this.callbacks.set(messageId, { resolver, rejecter });
|
||||
});
|
||||
message.messageId = messageId;
|
||||
try {
|
||||
await this.send(message);
|
||||
} catch (e) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Error sending message of type ${message.command} to Bitwarden Desktop app. Error: ${e}`,
|
||||
);
|
||||
const callback = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
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({
|
||||
message: "timeout",
|
||||
});
|
||||
this.callbacks.delete(messageId);
|
||||
}
|
||||
}, MessageNoResponseTimeout);
|
||||
|
||||
return callback;
|
||||
}
|
||||
|
||||
async send(message: Message) {
|
||||
if (!this.connected) {
|
||||
await this.connect();
|
||||
@@ -233,20 +327,7 @@ export class NativeMessagingBackground {
|
||||
return await this.encryptService.encrypt(JSON.stringify(message), this.sharedSecret);
|
||||
}
|
||||
|
||||
getResponse(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolver = function (response: any) {
|
||||
resolve(response);
|
||||
};
|
||||
this.rejecter = function (resp: any) {
|
||||
reject({
|
||||
message: resp,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private postMessage(message: OuterMessage) {
|
||||
private postMessage(message: OuterMessage, messageId?: number) {
|
||||
// Wrap in try-catch to when the port disconnected without triggering `onDisconnect`.
|
||||
try {
|
||||
const msg: any = message;
|
||||
@@ -262,13 +343,17 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
this.port.postMessage(msg);
|
||||
} catch (e) {
|
||||
this.logService.error("NativeMessaging port disconnected, disconnecting.");
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.connected = false;
|
||||
|
||||
this.rejecter("invalidateEncryption");
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).rejecter("invalidateEncryption");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,90 +370,30 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
this.logService.error("NativeMessage is to old, ignoring.");
|
||||
this.logService.info("[Native Messaging IPC] Received an old native message, ignoring...");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.command) {
|
||||
case "biometricUnlock": {
|
||||
if (
|
||||
["not available", "not enabled", "not supported", "not unlocked", "canceled"].includes(
|
||||
message.response,
|
||||
)
|
||||
) {
|
||||
this.rejecter(message.response);
|
||||
return;
|
||||
}
|
||||
const messageId = message.messageId;
|
||||
|
||||
// Check for initial setup of biometric unlock
|
||||
const enabled = await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$);
|
||||
if (enabled === null || enabled === false) {
|
||||
if (message.response === "unlocked") {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Ignore unlock if already unlocked
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.Unlocked) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.response === "unlocked") {
|
||||
try {
|
||||
if (message.userKeyB64) {
|
||||
const userKey = new SymmetricCryptoKey(
|
||||
Utils.fromB64ToArray(message.userKeyB64),
|
||||
) as UserKey;
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const isUserKeyValid = await this.keyService.validateUserKey(userKey, activeUserId);
|
||||
if (isUserKeyValid) {
|
||||
await this.keyService.setUserKey(userKey, activeUserId);
|
||||
} else {
|
||||
this.logService.error("Unable to verify biometric unlocked userkey");
|
||||
await this.keyService.clearKeys(activeUserId);
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
throw new Error("No key received");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error("Unable to set key: " + e);
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify key is correct by attempting to decrypt a secret
|
||||
try {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.keyService.getFingerprint(userId);
|
||||
} catch (e) {
|
||||
this.logService.error("Unable to verify key: " + e);
|
||||
await this.keyService.clearKeys();
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.runtimeBackground.processMessage({ command: "unlocked" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
this.resolver(message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logService.error("NativeMessage, got unknown command: " + message.command);
|
||||
break;
|
||||
if (
|
||||
message.command == BiometricsCommands.Unlock ||
|
||||
message.command == BiometricsCommands.IsAvailable
|
||||
) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Received legacy message of type ${message.command}`,
|
||||
);
|
||||
const messageId = this.callbacks.keys().next().value;
|
||||
const resolver = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
resolver.resolver(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.resolver) {
|
||||
this.resolver(message);
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).resolver(message);
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +409,7 @@ export class NativeMessagingBackground {
|
||||
command: "setupEncryption",
|
||||
publicKey: Utils.fromBufferToB64(publicKey),
|
||||
userId: userId,
|
||||
messageId: this.messageId++,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => (this.secureSetupResolve = resolve));
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { BiometricsCommands } from "@bitwarden/key-management";
|
||||
|
||||
import { MessageListener, isExternalMessage } from "../../../../libs/common/src/platform/messaging";
|
||||
import {
|
||||
@@ -71,8 +72,10 @@ export default class RuntimeBackground {
|
||||
sendResponse: (response: any) => void,
|
||||
) => {
|
||||
const messagesWithResponse = [
|
||||
"biometricUnlock",
|
||||
"biometricUnlockAvailable",
|
||||
BiometricsCommands.AuthenticateWithBiometrics,
|
||||
BiometricsCommands.GetBiometricsStatus,
|
||||
BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
BiometricsCommands.GetBiometricsStatusForUser,
|
||||
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||
"getInlineMenuFieldQualificationFeatureFlag",
|
||||
"getInlineMenuTotpFeatureFlag",
|
||||
@@ -185,13 +188,17 @@ export default class RuntimeBackground {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "biometricUnlock": {
|
||||
const result = await this.main.biometricsService.authenticateBiometric();
|
||||
return result;
|
||||
case BiometricsCommands.AuthenticateWithBiometrics: {
|
||||
return await this.main.biometricsService.authenticateWithBiometrics();
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
const result = await this.main.biometricsService.isBiometricUnlockAvailable();
|
||||
return result;
|
||||
case BiometricsCommands.GetBiometricsStatus: {
|
||||
return await this.main.biometricsService.getBiometricsStatus();
|
||||
}
|
||||
case BiometricsCommands.UnlockWithBiometricsForUser: {
|
||||
return await this.main.biometricsService.unlockWithBiometricsForUser(msg.userId);
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatusForUser: {
|
||||
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
|
||||
}
|
||||
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
|
||||
return await this.configService.getFeatureFlag(
|
||||
|
||||
@@ -1,36 +1,136 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsCommands, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserBiometricsService } from "./browser-biometrics.service";
|
||||
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundBrowserBiometricsService extends BrowserBiometricsService {
|
||||
constructor(private nativeMessagingBackground: () => NativeMessagingBackground) {
|
||||
export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
constructor(
|
||||
private nativeMessagingBackground: () => NativeMessagingBackground,
|
||||
private logService: LogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const responsePromise = this.nativeMessagingBackground().getResponse();
|
||||
await this.nativeMessagingBackground().send({ command: "biometricUnlock" });
|
||||
const response = await responsePromise;
|
||||
return response.response === "unlocked";
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.Unlock,
|
||||
});
|
||||
return response.response == "unlocked";
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.AuthenticateWithBiometrics,
|
||||
});
|
||||
return response.response;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info("Biometric authentication failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
const responsePromise = this.nativeMessagingBackground().getResponse();
|
||||
await this.nativeMessagingBackground().send({ command: "biometricUnlockAvailable" });
|
||||
const response = await responsePromise;
|
||||
return response.response === "available";
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
if (!(await BrowserApi.permissionsGranted(["nativeMessaging"]))) {
|
||||
return BiometricsStatus.NativeMessagingPermissionMissing;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
});
|
||||
const resp =
|
||||
response.response == "available"
|
||||
? BiometricsStatus.Available
|
||||
: BiometricsStatus.HardwareUnavailable;
|
||||
return resp;
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.GetBiometricsStatus,
|
||||
});
|
||||
|
||||
if (response.response) {
|
||||
return response.response;
|
||||
}
|
||||
}
|
||||
return BiometricsStatus.Available;
|
||||
} catch (e) {
|
||||
return BiometricsStatus.DesktopDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.Unlock,
|
||||
});
|
||||
if (response.response == "unlocked") {
|
||||
return response.userKeyB64;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
userId: userId,
|
||||
});
|
||||
if (response.response) {
|
||||
return response.userKeyB64;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info("Biometric unlock for user failed", e);
|
||||
throw new Error("Biometric unlock failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
return await this.getBiometricsStatus();
|
||||
}
|
||||
|
||||
return (
|
||||
await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.GetBiometricsStatusForUser,
|
||||
userId: id,
|
||||
})
|
||||
).response;
|
||||
} catch (e) {
|
||||
return BiometricsStatus.DesktopDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
// the first time we call, this might use an outdated version of the protocol, so we drop the response
|
||||
private async ensureConnected() {
|
||||
if (!this.nativeMessagingBackground().connected) {
|
||||
await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
@Injectable()
|
||||
export abstract class BrowserBiometricsService extends BiometricsService {
|
||||
async supportsBiometric() {
|
||||
const platformInfo = await BrowserApi.getPlatformInfo();
|
||||
if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
abstract authenticateBiometric(): Promise<boolean>;
|
||||
abstract isBiometricUnlockAvailable(): Promise<boolean>;
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsCommands, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { BrowserBiometricsService } from "./browser-biometrics.service";
|
||||
export class ForegroundBrowserBiometricsService extends BiometricsService {
|
||||
shouldAutopromptNow = true;
|
||||
|
||||
export class ForegroundBrowserBiometricsService extends BrowserBiometricsService {
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
error: string;
|
||||
}>("biometricUnlock");
|
||||
}>(BiometricsCommands.AuthenticateWithBiometrics);
|
||||
if (!response.result) {
|
||||
throw response.error;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
result: BiometricsStatus;
|
||||
error: string;
|
||||
}>("biometricUnlockAvailable");
|
||||
return response.result && response.result === true;
|
||||
}>(BiometricsCommands.GetBiometricsStatus);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
return false;
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: string;
|
||||
error: string;
|
||||
}>(BiometricsCommands.UnlockWithBiometricsForUser, { userId });
|
||||
if (!response.result) {
|
||||
return null;
|
||||
}
|
||||
return SymmetricCryptoKey.fromString(response.result) as UserKey;
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: BiometricsStatus;
|
||||
error: string;
|
||||
}>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id });
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {}
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutopromptNow;
|
||||
}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
this.shouldAutopromptNow = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
KdfConfigService,
|
||||
DefaultKeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
export class BrowserKeyService extends DefaultKeyService {
|
||||
constructor(
|
||||
pinService: PinServiceAbstraction,
|
||||
masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
keyGenerationService: KeyGenerationService,
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilService: PlatformUtilsService,
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
) {
|
||||
super(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilService,
|
||||
logService,
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
kdfConfigService,
|
||||
);
|
||||
}
|
||||
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId);
|
||||
return await biometricUnlockPromise;
|
||||
}
|
||||
return super.hasUserKeyStored(keySuffix, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser doesn't store biometric keys, so we retrieve them from the desktop and return
|
||||
* if we successfully saved it into memory as the User Key
|
||||
* @returns the `UserKey` if the user passes a biometrics prompt, otherwise return `null`.
|
||||
*/
|
||||
protected override async getKeyFromStorage(
|
||||
keySuffix: KeySuffixOptions,
|
||||
userId?: UserId,
|
||||
): Promise<UserKey> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricsResult = await this.biometricsService.authenticateBiometric();
|
||||
|
||||
if (!biometricsResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
if (userKey) {
|
||||
return userKey;
|
||||
}
|
||||
}
|
||||
|
||||
return await super.getKeyFromStorage(keySuffix, userId);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
@@ -121,8 +121,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
describe("getAvailableUnlockOptions$", () => {
|
||||
interface MockInputs {
|
||||
hasMasterPassword: boolean;
|
||||
osSupportsBiometric: boolean;
|
||||
biometricLockSet: boolean;
|
||||
biometricsStatusForUser: BiometricsStatus;
|
||||
hasBiometricEncryptedUserKeyStored: boolean;
|
||||
platformSupportsSecureStorage: boolean;
|
||||
pinDecryptionAvailable: boolean;
|
||||
@@ -133,8 +132,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// MP + PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: true,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
@@ -148,7 +146,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -156,8 +154,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
@@ -171,7 +168,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -179,8 +176,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics available: user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -194,7 +190,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -202,8 +198,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics available: no user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -217,7 +212,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -225,8 +220,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: biometric lock not set
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: false,
|
||||
biometricsStatusForUser: BiometricsStatus.UnlockNeeded,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -240,7 +234,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.UnlockNeeded,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -248,8 +242,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: user key not stored
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.NotEnabledInConnectedDesktopApp,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -263,7 +256,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -271,8 +264,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: OS doesn't support
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: false,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.HardwareUnavailable,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -286,7 +278,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -304,8 +296,12 @@ describe("ExtensionLockComponentService", () => {
|
||||
);
|
||||
|
||||
// Biometrics
|
||||
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
|
||||
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
|
||||
mockInputs.biometricsStatusForUser,
|
||||
);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(
|
||||
mockInputs.hasBiometricEncryptedUserKeyStored,
|
||||
);
|
||||
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
|
||||
@@ -7,27 +7,17 @@ import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import {
|
||||
LockComponentService,
|
||||
BiometricsDisableReason,
|
||||
UnlockOptions,
|
||||
} from "@bitwarden/key-management/angular";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
export class ExtensionLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
private readonly biometricsService = inject(BiometricsService);
|
||||
private readonly pinService = inject(PinServiceAbstraction);
|
||||
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private readonly keyService = inject(KeyService);
|
||||
private readonly routerService = inject(BrowserRouterService);
|
||||
|
||||
getPreviousUrl(): string | null {
|
||||
@@ -52,67 +42,28 @@ export class ExtensionLockComponentService implements LockComponentService {
|
||||
return "unlockWithBiometrics";
|
||||
}
|
||||
|
||||
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
|
||||
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
|
||||
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
|
||||
KeySuffixOptions.Biometric,
|
||||
userId,
|
||||
);
|
||||
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
|
||||
|
||||
return (
|
||||
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
|
||||
);
|
||||
}
|
||||
|
||||
private getBiometricsDisabledReason(
|
||||
osSupportsBiometric: boolean,
|
||||
biometricLockSet: boolean,
|
||||
): BiometricsDisableReason | null {
|
||||
if (!osSupportsBiometric) {
|
||||
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
|
||||
} else if (!biometricLockSet) {
|
||||
return BiometricsDisableReason.EncryptedKeysUnavailable;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(() => this.biometricsService.supportsBiometric()),
|
||||
defer(() => this.isBiometricLockSet(userId)),
|
||||
defer(async () => await this.biometricsService.getBiometricsStatusForUser(userId)),
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
userDecryptionOptions,
|
||||
pinDecryptionAvailable,
|
||||
]) => {
|
||||
const disableReason = this.getBiometricsDisabledReason(
|
||||
supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
);
|
||||
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: supportsBiometric && isBiometricsLockSet,
|
||||
disableReason: disableReason,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
},
|
||||
),
|
||||
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: biometricsStatus === BiometricsStatus.Available,
|
||||
biometricsStatus: biometricsStatus,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
import {
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
DefaultKeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management/angular";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
@@ -126,7 +126,6 @@ import { AutofillService as AutofillServiceAbstraction } from "../../autofill/se
|
||||
import AutofillService from "../../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
|
||||
import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics";
|
||||
import { BrowserKeyService } from "../../key-management/browser-key.service";
|
||||
import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator";
|
||||
@@ -232,11 +231,9 @@ const safeProviders: SafeProvider[] = [
|
||||
stateService: StateService,
|
||||
accountService: AccountServiceAbstraction,
|
||||
stateProvider: StateProvider,
|
||||
biometricStateService: BiometricStateService,
|
||||
biometricsService: BiometricsService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
) => {
|
||||
const keyService = new BrowserKeyService(
|
||||
const keyService = new DefaultKeyService(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
@@ -247,8 +244,6 @@ const safeProviders: SafeProvider[] = [
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
kdfConfigService,
|
||||
);
|
||||
new ContainerService(keyService, encryptService).attachToGlobal(self);
|
||||
@@ -265,8 +260,6 @@ const safeProviders: SafeProvider[] = [
|
||||
StateService,
|
||||
AccountServiceAbstraction,
|
||||
StateProvider,
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -86,8 +86,203 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "biometricUnlock":
|
||||
case "authenticateWithBiometrics":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
let laContext = LAContext()
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "authenticate") { (success, error) in
|
||||
if success {
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
} else {
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
}
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "getBiometricsStatus":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatus",
|
||||
"response": BiometricsStatus.Available.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil);
|
||||
break
|
||||
case "unlockWithBiometricsForUser":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
var error: NSError?
|
||||
let laContext = LAContext()
|
||||
|
||||
laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
|
||||
if let e = error, e.code != kLAErrorBiometryLockout {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
|
||||
let messageId = message?["messageId"] as? Int
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "unlock your vault") { (success, error) in
|
||||
if success {
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
}
|
||||
let passwordName = userId + "_user_biometric"
|
||||
var passwordLength: UInt32 = 0
|
||||
var passwordPtr: UnsafeMutableRawPointer? = nil
|
||||
|
||||
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
|
||||
if status != errSecSuccess {
|
||||
let fallbackName = "key"
|
||||
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
|
||||
}
|
||||
|
||||
if status == errSecSuccess {
|
||||
let result = NSString(bytes: passwordPtr!, length: Int(passwordLength), encoding: String.Encoding.utf8.rawValue) as String?
|
||||
SecKeychainItemFreeContent(nil, passwordPtr)
|
||||
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"userKeyB64": result!.replacingOccurrences(of: "\"", with: ""),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
} else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "getBiometricsStatusForUser":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
let laContext = LAContext()
|
||||
if !laContext.isBiometricsAvailable() {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.HardwareUnavailable.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
}
|
||||
let passwordName = userId + "_user_biometric"
|
||||
var passwordLength: UInt32 = 0
|
||||
var passwordPtr: UnsafeMutableRawPointer? = nil
|
||||
|
||||
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
|
||||
if status != errSecSuccess {
|
||||
let fallbackName = "key"
|
||||
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
|
||||
}
|
||||
|
||||
if status == errSecSuccess {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.Available.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
} else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.NotEnabledInConnectedDesktopApp.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
break
|
||||
case "biometricUnlock":
|
||||
var error: NSError?
|
||||
let laContext = LAContext()
|
||||
|
||||
if(!laContext.isBiometricsAvailable()){
|
||||
@@ -115,7 +310,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
]
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Bitwarden Safari Extension") { (success, error) in
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Biometric Unlock") { (success, error) in
|
||||
if success {
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
@@ -157,7 +352,6 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
|
||||
return
|
||||
case "biometricUnlockAvailable":
|
||||
let laContext = LAContext()
|
||||
@@ -228,3 +422,15 @@ class DownloadFileMessage: Decodable, Encodable {
|
||||
class DownloadFileMessageBlobOptions: Decodable, Encodable {
|
||||
var type: String?
|
||||
}
|
||||
|
||||
enum BiometricsStatus : Int {
|
||||
case Available = 0
|
||||
case UnlockNeeded = 1
|
||||
case HardwareUnavailable = 2
|
||||
case AutoSetupNeeded = 3
|
||||
case ManualSetupNeeded = 4
|
||||
case PlatformUnsupported = 5
|
||||
case DesktopDisconnected = 6
|
||||
case NotEnabledLocally = 7
|
||||
case NotEnabledInConnectedDesktopApp = 8
|
||||
}
|
||||
|
||||
27
apps/cli/src/key-management/cli-biometrics-service.ts
Normal file
27
apps/cli/src/key-management/cli-biometrics-service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
export class CliBiometricsService extends BiometricsService {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
|
||||
import { flagEnabled } from "../platform/flags";
|
||||
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
|
||||
import { ConsoleLogService } from "../platform/services/console-log.service";
|
||||
@@ -693,12 +694,12 @@ export class ServiceContainer {
|
||||
this.userVerificationApiService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.pinService,
|
||||
this.logService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.platformUtilsService,
|
||||
this.kdfConfigService,
|
||||
new CliBiometricsService(),
|
||||
);
|
||||
|
||||
const biometricService = new CliBiometricsService();
|
||||
|
||||
this.vaultTimeoutService = new VaultTimeoutService(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
@@ -714,6 +715,7 @@ export class ServiceContainer {
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
biometricService,
|
||||
lockedCallback,
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions, ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums/theme-type.enum";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -32,10 +32,11 @@ import {
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/types/vault-timeout.type";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { SetPinComponent } from "../../auth/components/set-pin.component";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
|
||||
|
||||
@@ -54,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
themeOptions: any[];
|
||||
clearClipboardOptions: any[];
|
||||
supportsBiometric: boolean;
|
||||
private timerId: any;
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
@@ -139,7 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private userVerificationService: UserVerificationServiceAbstraction,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
private biometricsService: DesktopBiometricsService,
|
||||
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private logService: LogService,
|
||||
@@ -297,7 +299,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// Non-form values
|
||||
this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop;
|
||||
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
this.supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
this.previousVaultTimeout = this.form.value.vaultTimeout;
|
||||
|
||||
this.refreshTimeoutSettings$
|
||||
@@ -360,6 +361,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.enableBrowserIntegrationFingerprint.disable();
|
||||
}
|
||||
});
|
||||
|
||||
this.supportsBiometric =
|
||||
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
|
||||
this.timerId = setInterval(async () => {
|
||||
this.supportsBiometric =
|
||||
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async saveVaultTimeout(newValue: VaultTimeout) {
|
||||
@@ -476,23 +484,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const needsSetup = await this.biometricsService.biometricsNeedsSetup();
|
||||
const supportsBiometricAutoSetup = await this.biometricsService.biometricsSupportsAutoSetup();
|
||||
const status = await this.biometricsService.getBiometricsStatus();
|
||||
|
||||
if (needsSetup) {
|
||||
if (supportsBiometricAutoSetup) {
|
||||
await this.biometricsService.biometricsSetup();
|
||||
} else {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "biometricsManualSetupTitle" },
|
||||
content: { key: "biometricsManualSetupDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
if (confirmed) {
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
|
||||
}
|
||||
return;
|
||||
if (status === BiometricsStatus.AutoSetupNeeded) {
|
||||
await this.biometricsService.setupBiometrics();
|
||||
} else if (status === BiometricsStatus.ManualSetupNeeded) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "biometricsManualSetupTitle" },
|
||||
content: { key: "biometricsManualSetupDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
if (confirmed) {
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(true);
|
||||
@@ -513,8 +518,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
await this.keyService.refreshAdditionalKeys();
|
||||
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
// Validate the key is stored in case biometrics fail.
|
||||
const biometricSet = await this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric);
|
||||
const biometricSet =
|
||||
(await this.biometricsService.getBiometricsStatusForUser(activeUserId)) ===
|
||||
BiometricsStatus.Available;
|
||||
this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
|
||||
if (!biometricSet) {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
@@ -779,6 +789,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
|
||||
get biometricText() {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
|
||||
type ActiveAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -90,6 +92,7 @@ export class AccountSwitcherComponent implements OnInit {
|
||||
private environmentService: EnvironmentService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private biometricsService: DesktopBiometricsService,
|
||||
) {
|
||||
this.activeAccount$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap(async (active) => {
|
||||
@@ -181,6 +184,7 @@ export class AccountSwitcherComponent implements OnInit {
|
||||
|
||||
async switch(userId: string) {
|
||||
this.close();
|
||||
await this.biometricsService.setShouldAutopromptNow(true);
|
||||
|
||||
this.disabled = true;
|
||||
const accountSwitchFinishedPromise = firstValueFrom(
|
||||
|
||||
@@ -102,7 +102,8 @@ import { DesktopLoginComponentService } from "../../auth/login/desktop-login-com
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
|
||||
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
|
||||
import { flagEnabled } from "../../platform/flags";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
@@ -142,7 +143,12 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider(InitService),
|
||||
safeProvider({
|
||||
provide: BiometricsService,
|
||||
useClass: ElectronBiometricsService,
|
||||
useClass: RendererBiometricsService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DesktopBiometricsService,
|
||||
useClass: RendererBiometricsService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider(NativeMessagingService),
|
||||
@@ -241,6 +247,7 @@ const safeProviders: SafeProvider[] = [
|
||||
VaultTimeoutSettingsService,
|
||||
BiometricStateService,
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -302,6 +309,7 @@ const safeProviders: SafeProvider[] = [
|
||||
StateProvider,
|
||||
BiometricStateService,
|
||||
KdfConfigService,
|
||||
DesktopBiometricsService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
|
||||
export default class NoopBiometricsService implements OsBiometricService {
|
||||
constructor() {}
|
||||
|
||||
async init() {}
|
||||
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsNeedsSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsCanAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsSetup(): Promise<void> {}
|
||||
|
||||
async getBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyHalfB64: string,
|
||||
): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
value: string,
|
||||
clientKeyPartB64: string | undefined,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
throw new Error("Not supported on this platform");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
export class BiometricsRendererIPCListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
|
||||
try {
|
||||
let serviceName = this.serviceName;
|
||||
message.keySuffix = "_" + (message.keySuffix ?? "");
|
||||
if (message.keySuffix !== "_") {
|
||||
serviceName += message.keySuffix;
|
||||
}
|
||||
|
||||
let val: string | boolean = null;
|
||||
|
||||
if (!message.action) {
|
||||
return val;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case BiometricAction.EnabledForUser:
|
||||
if (!message.key || !message.userId) {
|
||||
break;
|
||||
}
|
||||
val = await this.biometricService.canAuthBiometric({
|
||||
service: serviceName,
|
||||
key: message.key,
|
||||
userId: message.userId,
|
||||
});
|
||||
break;
|
||||
case BiometricAction.OsSupported:
|
||||
val = await this.biometricService.supportsBiometric();
|
||||
break;
|
||||
case BiometricAction.NeedsSetup:
|
||||
val = await this.biometricService.biometricsNeedsSetup();
|
||||
break;
|
||||
case BiometricAction.Setup:
|
||||
await this.biometricService.biometricsSetup();
|
||||
break;
|
||||
case BiometricAction.CanAutoSetup:
|
||||
val = await this.biometricService.biometricsSupportsAutoSetup();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return val;
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import BiometricDarwinMain from "./biometric.darwin.main";
|
||||
import BiometricWindowsMain from "./biometric.windows.main";
|
||||
import { BiometricsService } from "./biometrics.service";
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { MainBiometricsService } from "./main-biometrics.service";
|
||||
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
|
||||
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
return {
|
||||
@@ -28,8 +33,7 @@ describe("biometrics tests", function () {
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
|
||||
it("Should call the platformspecific methods", async () => {
|
||||
const userId = "userId-1" as UserId;
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -39,21 +43,15 @@ describe("biometrics tests", function () {
|
||||
);
|
||||
|
||||
const mockService = mock<OsBiometricService>();
|
||||
(sut as any).platformSpecificService = mockService;
|
||||
await sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
(sut as any).osBiometricsService = mockService;
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
expect(mockService.osSupportsBiometric).toBeCalled();
|
||||
|
||||
// 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
|
||||
sut.authenticateBiometric();
|
||||
await sut.authenticateBiometric();
|
||||
expect(mockService.authenticateBiometric).toBeCalled();
|
||||
});
|
||||
|
||||
describe("Should create a platform specific service", function () {
|
||||
it("Should create a biometrics service specific for Windows", () => {
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -62,13 +60,13 @@ describe("biometrics tests", function () {
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
const internalService = (sut as any).platformSpecificService;
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(BiometricWindowsMain);
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for MacOs", () => {
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -76,19 +74,33 @@ describe("biometrics tests", function () {
|
||||
"darwin",
|
||||
biometricStateService,
|
||||
);
|
||||
const internalService = (sut as any).platformSpecificService;
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceMac);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for Linux", () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
messagingService,
|
||||
"linux",
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceLinux);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can auth biometric", () => {
|
||||
let sut: BiometricsService;
|
||||
let innerService: MockProxy<OsBiometricService>;
|
||||
const userId = "userId-1" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BiometricsService(
|
||||
sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -98,34 +110,78 @@ describe("biometrics tests", function () {
|
||||
);
|
||||
|
||||
innerService = mock();
|
||||
(sut as any).platformSpecificService = innerService;
|
||||
(sut as any).osBiometricsService = innerService;
|
||||
});
|
||||
|
||||
it("should return false if client key half is required and not provided", async () => {
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
|
||||
it("should return the correct biometric status for system status", async () => {
|
||||
const testCases = [
|
||||
// happy path
|
||||
[true, false, false, BiometricsStatus.Available],
|
||||
[false, true, true, BiometricsStatus.AutoSetupNeeded],
|
||||
[false, true, false, BiometricsStatus.ManualSetupNeeded],
|
||||
[false, false, false, BiometricsStatus.HardwareUnavailable],
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
// should not happen
|
||||
[false, false, true, BiometricsStatus.HardwareUnavailable],
|
||||
[true, true, true, BiometricsStatus.Available],
|
||||
[true, true, false, BiometricsStatus.Available],
|
||||
[true, false, true, BiometricsStatus.Available],
|
||||
];
|
||||
|
||||
expect(result).toBe(false);
|
||||
for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) {
|
||||
innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean);
|
||||
innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean);
|
||||
innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean);
|
||||
|
||||
const actual = await sut.getBiometricsStatus();
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should call osSupportsBiometric if client key half is provided", async () => {
|
||||
// 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
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
it("should return the correct biometric status for user status", async () => {
|
||||
const testCases = [
|
||||
// system status, biometric unlock enabled, require password on start, has key half, result
|
||||
[BiometricsStatus.Available, false, false, false, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, true, false, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, false, true, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, true, true, BiometricsStatus.NotEnabledLocally],
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
[
|
||||
BiometricsStatus.PlatformUnsupported,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
BiometricsStatus.PlatformUnsupported,
|
||||
],
|
||||
[BiometricsStatus.ManualSetupNeeded, true, true, true, BiometricsStatus.ManualSetupNeeded],
|
||||
[BiometricsStatus.AutoSetupNeeded, true, true, true, BiometricsStatus.AutoSetupNeeded],
|
||||
|
||||
it("should call osSupportBiometric if client key half is not required", async () => {
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(false);
|
||||
innerService.osSupportsBiometric.mockResolvedValue(true);
|
||||
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
|
||||
[BiometricsStatus.Available, true, true, false, BiometricsStatus.UnlockNeeded],
|
||||
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
|
||||
];
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
for (const [
|
||||
systemStatus,
|
||||
unlockEnabled,
|
||||
requirePasswordOnStart,
|
||||
hasKeyHalf,
|
||||
expected,
|
||||
] of testCases) {
|
||||
sut.getBiometricsStatus = jest.fn().mockResolvedValue(systemStatus as BiometricsStatus);
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(unlockEnabled as boolean);
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(
|
||||
requirePasswordOnStart as boolean,
|
||||
);
|
||||
(sut as any).clientKeyHalves = new Map();
|
||||
const userId = "test" as UserId;
|
||||
if (hasKeyHalf) {
|
||||
(sut as any).clientKeyHalves.set(userId, "test");
|
||||
}
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(innerService.osSupportsBiometric).toHaveBeenCalled();
|
||||
const actual = await sut.getBiometricsStatusForUser(userId);
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// 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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { DesktopBiometricsService, OsBiometricService } from "./desktop.biometrics.service";
|
||||
|
||||
export class BiometricsService extends DesktopBiometricsService {
|
||||
private platformSpecificService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform,
|
||||
private biometricStateService: BiometricStateService,
|
||||
) {
|
||||
super();
|
||||
this.loadPlatformSpecificService(this.platform);
|
||||
}
|
||||
|
||||
private loadPlatformSpecificService(platform: NodeJS.Platform) {
|
||||
if (platform === "win32") {
|
||||
this.loadWindowsHelloService();
|
||||
} else if (platform === "darwin") {
|
||||
this.loadMacOSService();
|
||||
} else if (platform === "linux") {
|
||||
this.loadUnixService();
|
||||
} else {
|
||||
this.loadNoopBiometricsService();
|
||||
}
|
||||
}
|
||||
|
||||
private loadWindowsHelloService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricWindowsMain = require("./biometric.windows.main").default;
|
||||
this.platformSpecificService = new BiometricWindowsMain(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
private loadMacOSService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricDarwinMain = require("./biometric.darwin.main").default;
|
||||
this.platformSpecificService = new BiometricDarwinMain(this.i18nService);
|
||||
}
|
||||
|
||||
private loadUnixService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricUnixMain = require("./biometric.unix.main").default;
|
||||
this.platformSpecificService = new BiometricUnixMain(this.i18nService, this.windowMain);
|
||||
}
|
||||
|
||||
private loadNoopBiometricsService() {
|
||||
// eslint-disable-next-line
|
||||
const NoopBiometricsService = require("./biometric.noop.main").default;
|
||||
this.platformSpecificService = new NoopBiometricsService();
|
||||
}
|
||||
|
||||
async supportsBiometric() {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup() {
|
||||
return await this.platformSpecificService.osBiometricsNeedsSetup();
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup() {
|
||||
return await this.platformSpecificService.osBiometricsCanAutoSetup();
|
||||
}
|
||||
|
||||
async biometricsSetup() {
|
||||
await this.platformSpecificService.osBiometricsSetup();
|
||||
}
|
||||
|
||||
async canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: UserId;
|
||||
}): Promise<boolean> {
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
return clientKeyHalfSatisfied && (await this.supportsBiometric());
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
let result = false;
|
||||
// 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.interruptProcessReload(
|
||||
() => {
|
||||
return this.platformSpecificService.authenticateBiometric();
|
||||
},
|
||||
(response) => {
|
||||
result = response;
|
||||
return !response;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async getBiometricKey(service: string, storageKey: string): Promise<string | null> {
|
||||
return await this.interruptProcessReload(async () => {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.getBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
this.getClientKeyHalf(service, storageKey),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async setBiometricKey(service: string, storageKey: string, value: string): Promise<void> {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.setBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
this.getClientKeyHalf(service, storageKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers the client-side encryption key half for the OS stored Biometric key. The other half is protected by the OS.*/
|
||||
async setEncryptionKeyHalf({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
if (value == null) {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, key));
|
||||
} else {
|
||||
this.clientKeyHalves.set(this.clientKeyHalfKey(service, key), value);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, storageKey: string): Promise<void> {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, storageKey));
|
||||
return await this.platformSpecificService.deleteBiometricKey(service, storageKey);
|
||||
}
|
||||
|
||||
private async interruptProcessReload<T>(
|
||||
callback: () => Promise<T>,
|
||||
restartReloadCallback: (arg: T) => boolean = () => false,
|
||||
): Promise<T> {
|
||||
this.messagingService.send("cancelProcessReload");
|
||||
let restartReload = false;
|
||||
let response: T;
|
||||
try {
|
||||
response = await callback();
|
||||
restartReload ||= restartReloadCallback(response);
|
||||
} catch (error) {
|
||||
if (error.message === "Biometric authentication failed") {
|
||||
restartReload = false;
|
||||
} else {
|
||||
restartReload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (restartReload) {
|
||||
this.messagingService.send("startProcessReload");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private clientKeyHalfKey(service: string, key: string): string {
|
||||
return `${service}:${key}`;
|
||||
}
|
||||
|
||||
private getClientKeyHalf(service: string, key: string): string | undefined {
|
||||
return this.clientKeyHalves.get(this.clientKeyHalfKey(service, key)) ?? undefined;
|
||||
}
|
||||
|
||||
private async enforceClientKeyHalf(service: string, storageKey: string): Promise<void> {
|
||||
// The first half of the storageKey is the userId, separated by `_`
|
||||
// We need to extract from the service because the active user isn't properly synced to the main process,
|
||||
// So we can't use the observables on `biometricStateService`
|
||||
const [userId] = storageKey.split("_");
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(
|
||||
userId as UserId,
|
||||
);
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, storageKey);
|
||||
|
||||
if (requireClientKeyHalf && !clientKeyHalfB64) {
|
||||
throw new Error("Biometric key requirements not met. No client key half provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
/**
|
||||
@@ -5,58 +6,10 @@ import { BiometricsService } from "@bitwarden/key-management";
|
||||
* specifically for the main process.
|
||||
*/
|
||||
export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
abstract canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}): Promise<boolean>;
|
||||
abstract getBiometricKey(service: string, key: string): Promise<string | null>;
|
||||
abstract setBiometricKey(service: string, key: string, value: string): Promise<void>;
|
||||
abstract setEncryptionKeyHalf({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): void;
|
||||
abstract deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
}
|
||||
abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void>;
|
||||
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
|
||||
|
||||
export interface OsBiometricService {
|
||||
osSupportsBiometric(): Promise<boolean>;
|
||||
/**
|
||||
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
|
||||
*
|
||||
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
|
||||
*/
|
||||
osBiometricsNeedsSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Check whether biometrics can be automatically setup, or requires user interaction.
|
||||
*
|
||||
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
|
||||
*/
|
||||
osBiometricsCanAutoSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
|
||||
*/
|
||||
osBiometricsSetup: () => Promise<void>;
|
||||
authenticateBiometric(): Promise<boolean>;
|
||||
getBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<string | null>;
|
||||
setBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
value: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<void>;
|
||||
deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
abstract setupBiometrics(): Promise<void>;
|
||||
|
||||
abstract setClientKeyHalfForUser(userId: UserId, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
/**
|
||||
* This service implement the base biometrics service to provide desktop specific functions,
|
||||
* specifically for the renderer process by passing messages to the main process.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ElectronBiometricsService extends BiometricsService {
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.osSupported();
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.osSupported();
|
||||
}
|
||||
|
||||
/** This method is used to authenticate the user presence _only_.
|
||||
* It should not be used in the process to retrieve
|
||||
* biometric keys, which has a separate authentication mechanism.
|
||||
* For biometric keys, invoke "keytar" with a biometric key suffix */
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.authenticate();
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.biometricsNeedsSetup();
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.biometricsCanAutoSetup();
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.biometricsSetup();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./desktop.biometrics.service";
|
||||
export * from "./biometrics.service";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
export class MainBiometricsIPCListener {
|
||||
constructor(
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
|
||||
try {
|
||||
if (!message.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case BiometricAction.Authenticate:
|
||||
return await this.biometricService.authenticateWithBiometrics();
|
||||
case BiometricAction.GetStatus:
|
||||
return await this.biometricService.getBiometricsStatus();
|
||||
case BiometricAction.UnlockForUser:
|
||||
return await this.biometricService.unlockWithBiometricsForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
case BiometricAction.GetStatusForUser:
|
||||
return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId);
|
||||
case BiometricAction.SetKeyForUser:
|
||||
return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
);
|
||||
case BiometricAction.RemoveKeyForUser:
|
||||
return await this.biometricService.deleteBiometricUnlockKeyForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
case BiometricAction.SetClientKeyHalf:
|
||||
return await this.biometricService.setClientKeyHalfForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
);
|
||||
case BiometricAction.Setup:
|
||||
return await this.biometricService.setupBiometrics();
|
||||
|
||||
case BiometricAction.SetShouldAutoprompt:
|
||||
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
|
||||
case BiometricAction.GetShouldAutoprompt:
|
||||
return await this.biometricService.getShouldAutopromptNow();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private osBiometricsService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
private shouldAutoPrompt = true;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform,
|
||||
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;
|
||||
this.osBiometricsService = new OsBiometricsServiceWindows(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
} else if (platform === "darwin") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService);
|
||||
} else if (platform === "linux") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceLinux(this.i18nService, this.windowMain);
|
||||
} else {
|
||||
throw new Error("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of biometrics for the platform. Biometrics status for the platform can be one of:
|
||||
* - Available: Biometrics are available and can be used (On windows hello, (touch id (for now)) and polkit, this MAY fall back to password)
|
||||
* - HardwareUnavailable: Biometrics are not available on the platform
|
||||
* - ManualSetupNeeded: In order to use biometrics, the user must perform manual steps (linux only)
|
||||
* - AutoSetupNeeded: In order to use biometrics, the user must perform automatic steps (linux only)
|
||||
* @returns the status of the biometrics of the platform
|
||||
*/
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
if (!(await this.osBiometricsService.osSupportsBiometric())) {
|
||||
if (await this.osBiometricsService.osBiometricsNeedsSetup()) {
|
||||
if (await this.osBiometricsService.osBiometricsCanAutoSetup()) {
|
||||
return BiometricsStatus.AutoSetupNeeded;
|
||||
} else {
|
||||
return BiometricsStatus.ManualSetupNeeded;
|
||||
}
|
||||
}
|
||||
|
||||
return BiometricsStatus.HardwareUnavailable;
|
||||
}
|
||||
return BiometricsStatus.Available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of biometric unlock for a specific user. For this, biometric unlock needs to be set up for the user in the settings.
|
||||
* Next, biometrics unlock needs to be available on the platform level. If "masterpassword reprompt" is enabled, a client key half (set on first unlock) for this user
|
||||
* needs to be held in memory.
|
||||
* @param userId the user to check the biometric unlock status for
|
||||
* @returns the status of the biometric unlock for the user
|
||||
*/
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
if (!(await this.biometricStateService.getBiometricUnlockEnabled(userId))) {
|
||||
return BiometricsStatus.NotEnabledLocally;
|
||||
}
|
||||
|
||||
const platformStatus = await this.getBiometricsStatus();
|
||||
if (!(platformStatus === BiometricsStatus.Available)) {
|
||||
return platformStatus;
|
||||
}
|
||||
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
|
||||
const clientKeyHalfB64 = this.clientKeyHalves.get(userId);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
if (!clientKeyHalfSatisfied) {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
|
||||
return BiometricsStatus.Available;
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return await this.osBiometricsService.authenticateBiometric();
|
||||
}
|
||||
|
||||
async setupBiometrics(): Promise<void> {
|
||||
return await this.osBiometricsService.osBiometricsSetup();
|
||||
}
|
||||
|
||||
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
|
||||
this.clientKeyHalves.set(userId, value);
|
||||
}
|
||||
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return await this.osBiometricsService.authenticateBiometric();
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return SymmetricCryptoKey.fromString(
|
||||
await this.osBiometricsService.getBiometricKey(
|
||||
"Bitwarden_biometric",
|
||||
`${userId}_user_biometric`,
|
||||
this.clientKeyHalves.get(userId),
|
||||
),
|
||||
) as UserKey;
|
||||
}
|
||||
|
||||
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
|
||||
const service = "Bitwarden_biometric";
|
||||
const storageKey = `${userId}_user_biometric`;
|
||||
if (!this.clientKeyHalves.has(userId)) {
|
||||
throw new Error("No client key half provided for user");
|
||||
}
|
||||
|
||||
return await this.osBiometricsService.setBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
this.clientKeyHalves.get(userId),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
|
||||
return await this.osBiometricsService.deleteBiometricKey(
|
||||
"Bitwarden_biometric",
|
||||
`${userId}_user_biometric`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to auto-prompt the user for biometric unlock; this can be used to prevent auto-prompting being initiated by a process reload.
|
||||
* Reasons for enabling auto prompt include: Starting the app, un-minimizing the app, manually account switching
|
||||
* @param value Whether to auto-prompt the user for biometric unlock
|
||||
*/
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
this.shouldAutoPrompt = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether to auto-prompt the user for biometric unlock; If the user is auto-prompted, setShouldAutopromptNow should be immediately called with false in order to prevent another auto-prompt.
|
||||
* @returns Whether to auto-prompt the user for biometric unlock
|
||||
*/
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutoPrompt;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
@@ -30,7 +30,7 @@ const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
const policyFileName = "com.bitwarden.Bitwarden.policy";
|
||||
const policyPath = "/usr/share/polkit-1/actions/";
|
||||
|
||||
export default class BiometricUnixMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceLinux implements OsBiometricService {
|
||||
constructor(
|
||||
private i18nservice: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
@@ -3,9 +3,9 @@ import { systemPreferences } from "electron";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
export default class BiometricDarwinMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceMac implements OsBiometricService {
|
||||
constructor(private i18nservice: I18nService) {}
|
||||
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
@@ -8,12 +8,12 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const KEY_WITNESS_SUFFIX = "_witness";
|
||||
const WITNESS_VALUE = "known key";
|
||||
|
||||
export default class BiometricWindowsMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
@@ -113,13 +113,19 @@ export default class BiometricWindowsMain implements OsBiometricService {
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.key_material.clientKeyPartB64 == null) {
|
||||
delete result.key_material.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
@@ -211,10 +217,17 @@ export default class BiometricWindowsMain implements OsBiometricService {
|
||||
clientKeyPartB64: string,
|
||||
): biometrics.KeyMaterial {
|
||||
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
|
||||
return {
|
||||
|
||||
const result = {
|
||||
osKeyPartB64: key,
|
||||
clientKeyPartB64,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.clientKeyPartB64 == null) {
|
||||
delete result.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async osBiometricsNeedsSetup() {
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface OsBiometricService {
|
||||
osSupportsBiometric(): Promise<boolean>;
|
||||
/**
|
||||
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
|
||||
*
|
||||
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
|
||||
*/
|
||||
osBiometricsNeedsSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Check whether biometrics can be automatically setup, or requires user interaction.
|
||||
*
|
||||
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
|
||||
*/
|
||||
osBiometricsCanAutoSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
|
||||
*/
|
||||
osBiometricsSetup: () => Promise<void>;
|
||||
authenticateBiometric(): Promise<boolean>;
|
||||
getBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<string | null>;
|
||||
setBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
value: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<void>;
|
||||
deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
/**
|
||||
* This service implement the base biometrics service to provide desktop specific functions,
|
||||
* specifically for the renderer process by passing messages to the main process.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.authenticateWithBiometrics();
|
||||
}
|
||||
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return await ipc.keyManagement.biometric.getBiometricsStatus();
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return await ipc.keyManagement.biometric.unlockWithBiometricsForUser(userId);
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id);
|
||||
}
|
||||
|
||||
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value);
|
||||
}
|
||||
|
||||
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.deleteBiometricUnlockKeyForUser(userId);
|
||||
}
|
||||
|
||||
async setupBiometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setupBiometrics();
|
||||
}
|
||||
|
||||
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value);
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.getShouldAutoprompt();
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setShouldAutoprompt(value);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { DesktopLockComponentService } from "./desktop-lock-component.service";
|
||||
|
||||
@@ -140,11 +140,7 @@ describe("DesktopLockComponentService", () => {
|
||||
describe("getAvailableUnlockOptions$", () => {
|
||||
interface MockInputs {
|
||||
hasMasterPassword: boolean;
|
||||
osSupportsBiometric: boolean;
|
||||
biometricLockSet: boolean;
|
||||
biometricReady: boolean;
|
||||
hasBiometricEncryptedUserKeyStored: boolean;
|
||||
platformSupportsSecureStorage: boolean;
|
||||
biometricsStatus: BiometricsStatus;
|
||||
pinDecryptionAvailable: boolean;
|
||||
}
|
||||
|
||||
@@ -153,11 +149,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// MP + PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: true,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
@@ -169,7 +161,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -177,11 +169,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
@@ -193,43 +181,16 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: no user key stored with no secure storage
|
||||
// Biometric auth is available, but not unlock since there is no way to access the userkey
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -240,8 +201,8 @@ describe("DesktopLockComponentService", () => {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -249,11 +210,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// Biometrics not available: biometric not ready
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: false,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -265,55 +222,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.SystemBiometricsUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: biometric lock not set
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: false,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: user key not stored
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -321,11 +230,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// Biometrics not available: OS doesn't support
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: false,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -337,7 +242,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -355,13 +260,8 @@ describe("DesktopLockComponentService", () => {
|
||||
);
|
||||
|
||||
// Biometrics
|
||||
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
|
||||
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
);
|
||||
biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady);
|
||||
// TODO: FIXME
|
||||
biometricsService.getBiometricsStatusForUser.mockResolvedValue(mockInputs.biometricsStatus);
|
||||
|
||||
// PIN
|
||||
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
|
||||
|
||||
@@ -5,25 +5,17 @@ import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsDisableReason,
|
||||
LockComponentService,
|
||||
UnlockOptions,
|
||||
} from "@bitwarden/key-management/angular";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
export class DesktopLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
private readonly biometricsService = inject(BiometricsService);
|
||||
private readonly pinService = inject(PinServiceAbstraction);
|
||||
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private readonly keyService = inject(KeyService);
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -52,77 +44,29 @@ export class DesktopLockComponentService implements LockComponentService {
|
||||
}
|
||||
}
|
||||
|
||||
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
|
||||
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
|
||||
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
|
||||
KeySuffixOptions.Biometric,
|
||||
userId,
|
||||
);
|
||||
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
|
||||
|
||||
return (
|
||||
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
|
||||
);
|
||||
}
|
||||
|
||||
private async isBiometricsSupportedAndReady(
|
||||
userId: UserId,
|
||||
): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> {
|
||||
const supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
const biometricReady = await ipc.keyManagement.biometric.enabled(userId);
|
||||
return { supportsBiometric, biometricReady };
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(() => this.isBiometricsSupportedAndReady(userId)),
|
||||
defer(() => this.isBiometricLockSet(userId)),
|
||||
defer(() => this.biometricsService.getBiometricsStatusForUser(userId)),
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
|
||||
]).pipe(
|
||||
map(
|
||||
([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const disableReason = this.getBiometricsDisabledReason(
|
||||
biometricsData.supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
biometricsData.biometricReady,
|
||||
);
|
||||
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: biometricsStatus == BiometricsStatus.Available,
|
||||
biometricsStatus: biometricsStatus,
|
||||
},
|
||||
};
|
||||
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled:
|
||||
biometricsData.supportsBiometric &&
|
||||
isBiometricsLockSet &&
|
||||
biometricsData.biometricReady,
|
||||
disableReason: disableReason,
|
||||
},
|
||||
};
|
||||
|
||||
return unlockOpts;
|
||||
},
|
||||
),
|
||||
return unlockOpts;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getBiometricsDisabledReason(
|
||||
osSupportsBiometric: boolean,
|
||||
biometricLockSet: boolean,
|
||||
biometricReady: boolean,
|
||||
): BiometricsDisableReason | null {
|
||||
if (!osSupportsBiometric) {
|
||||
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
|
||||
} else if (!biometricLockSet) {
|
||||
return BiometricsDisableReason.EncryptedKeysUnavailable;
|
||||
} else if (!biometricReady) {
|
||||
return BiometricsDisableReason.SystemBiometricsUnavailable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../types/biometric-message";
|
||||
|
||||
const biometric = {
|
||||
enabled: (userId: string): Promise<boolean> =>
|
||||
authenticateWithBiometrics: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnabledForUser,
|
||||
key: `${userId}_user_biometric`,
|
||||
keySuffix: KeySuffixOptions.Biometric,
|
||||
action: BiometricAction.Authenticate,
|
||||
} satisfies BiometricMessage),
|
||||
getBiometricsStatus: (): Promise<BiometricsStatus> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.GetStatus,
|
||||
} satisfies BiometricMessage),
|
||||
unlockWithBiometricsForUser: (userId: string): Promise<UserKey | null> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.UnlockForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
osSupported: (): Promise<boolean> =>
|
||||
getBiometricsStatusForUser: (userId: string): Promise<BiometricsStatus> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.OsSupported,
|
||||
action: BiometricAction.GetStatusForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsNeedsSetup: (): Promise<boolean> =>
|
||||
setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.NeedsSetup,
|
||||
action: BiometricAction.SetKeyForUser,
|
||||
userId: userId,
|
||||
key: value,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsSetup: (): Promise<void> =>
|
||||
deleteBiometricUnlockKeyForUser: (userId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.RemoveKeyForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
setupBiometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.Setup,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsCanAutoSetup: (): Promise<boolean> =>
|
||||
setClientKeyHalf: (userId: string, value: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.CanAutoSetup,
|
||||
action: BiometricAction.SetClientKeyHalf,
|
||||
userId: userId,
|
||||
key: value,
|
||||
} satisfies BiometricMessage),
|
||||
authenticate: (): Promise<boolean> =>
|
||||
getShouldAutoprompt: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.Authenticate,
|
||||
action: BiometricAction.GetShouldAutoprompt,
|
||||
} satisfies BiometricMessage),
|
||||
setShouldAutoprompt: (should: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.SetShouldAutoprompt,
|
||||
data: should,
|
||||
} satisfies BiometricMessage),
|
||||
};
|
||||
|
||||
|
||||
@@ -3362,6 +3362,30 @@
|
||||
"ssoError": {
|
||||
"message": "No free ports could be found for the sso login."
|
||||
},
|
||||
"biometricsStatusHelptextUnlockNeeded": {
|
||||
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
|
||||
},
|
||||
"biometricsStatusHelptextHardwareUnavailable": {
|
||||
"message": "Biometric unlock is currently unavailable."
|
||||
},
|
||||
"biometricsStatusHelptextAutoSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextManualSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextNotEnabledLocally": {
|
||||
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "mail@example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"biometricsStatusHelptextUnavailableReasonUnknown": {
|
||||
"message": "Biometric unlock is currently unavailable for an unknown reason."
|
||||
},
|
||||
"authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
|
||||
@@ -28,8 +28,9 @@ import { DefaultBiometricStateService } from "@bitwarden/key-management";
|
||||
/* eslint-enable import/no-restricted-paths */
|
||||
|
||||
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
||||
import { BiometricsRendererIPCListener } from "./key-management/biometrics/biometric.renderer-ipc.listener";
|
||||
import { BiometricsService, DesktopBiometricsService } from "./key-management/biometrics/index";
|
||||
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
||||
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
||||
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
||||
import { MenuMain } from "./main/menu/menu.main";
|
||||
import { MessagingMain } from "./main/messaging.main";
|
||||
import { NativeMessagingMain } from "./main/native-messaging.main";
|
||||
@@ -61,7 +62,7 @@ export class Main {
|
||||
messagingService: MessageSender;
|
||||
environmentService: DefaultEnvironmentService;
|
||||
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
||||
biometricsRendererIPCListener: BiometricsRendererIPCListener;
|
||||
mainBiometricsIpcListener: MainBiometricsIPCListener;
|
||||
desktopSettingsService: DesktopSettingsService;
|
||||
mainCryptoFunctionService: MainCryptoFunctionService;
|
||||
migrationRunner: MigrationRunner;
|
||||
@@ -177,6 +178,15 @@ export class Main {
|
||||
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
|
||||
const biometricStateService = new DefaultBiometricStateService(stateProvider);
|
||||
|
||||
this.biometricsService = new MainBiometricsService(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.messagingService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
this.windowMain = new WindowMain(
|
||||
biometricStateService,
|
||||
this.logService,
|
||||
@@ -187,7 +197,6 @@ export class Main {
|
||||
);
|
||||
this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
|
||||
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
|
||||
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService);
|
||||
|
||||
const messageSubject = new Subject<Message<Record<string, unknown>>>();
|
||||
this.messagingService = MessageSender.combine(
|
||||
@@ -218,22 +227,19 @@ export class Main {
|
||||
this.versionMain,
|
||||
);
|
||||
|
||||
this.biometricsService = new BiometricsService(
|
||||
this.i18nService,
|
||||
this.trayMain = new TrayMain(
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.messagingService,
|
||||
process.platform,
|
||||
this.i18nService,
|
||||
this.desktopSettingsService,
|
||||
biometricStateService,
|
||||
this.biometricsService,
|
||||
);
|
||||
|
||||
this.desktopCredentialStorageListener = new DesktopCredentialStorageListener(
|
||||
"Bitwarden",
|
||||
this.biometricsService,
|
||||
this.logService,
|
||||
);
|
||||
this.biometricsRendererIPCListener = new BiometricsRendererIPCListener(
|
||||
"Bitwarden",
|
||||
this.mainBiometricsIpcListener = new MainBiometricsIPCListener(
|
||||
this.biometricsService,
|
||||
this.logService,
|
||||
);
|
||||
@@ -267,7 +273,7 @@ export class Main {
|
||||
|
||||
bootstrap() {
|
||||
this.desktopCredentialStorageListener.init();
|
||||
this.biometricsRendererIPCListener.init();
|
||||
this.mainBiometricsIpcListener.init();
|
||||
// Run migrations first, then other things
|
||||
this.migrationRunner.run().then(
|
||||
async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
@@ -23,6 +24,8 @@ export class TrayMain {
|
||||
private windowMain: WindowMain,
|
||||
private i18nService: I18nService,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private biometricsStateService: BiometricStateService,
|
||||
private biometricService: BiometricsService,
|
||||
) {
|
||||
if (process.platform === "win32") {
|
||||
this.icon = path.join(__dirname, "/images/icon.ico");
|
||||
@@ -72,6 +75,10 @@ export class TrayMain {
|
||||
}
|
||||
});
|
||||
|
||||
win.on("restore", async () => {
|
||||
await this.biometricService.setShouldAutopromptNow(true);
|
||||
});
|
||||
|
||||
win.on("close", async (e: Event) => {
|
||||
if (await firstValueFrom(this.desktopSettingsService.closeToTray$)) {
|
||||
if (!this.windowMain.isQuitting) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type LegacyMessage = {
|
||||
command: string;
|
||||
messageId: number;
|
||||
|
||||
userId?: string;
|
||||
timestamp?: number;
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
// @ts-strict-ignore
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/index";
|
||||
|
||||
const AuthRequiredSuffix = "_biometric";
|
||||
|
||||
export class DesktopCredentialStorageListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
@@ -54,13 +48,7 @@ export class DesktopCredentialStorageListener {
|
||||
|
||||
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
|
||||
private async getPassword(serviceName: string, key: string, keySuffix: string) {
|
||||
let val: string;
|
||||
// todo: remove this when biometrics has been migrated to desktop_native
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
|
||||
} else {
|
||||
val = await passwords.getPassword(serviceName, key);
|
||||
}
|
||||
const val = await passwords.getPassword(serviceName, key);
|
||||
|
||||
try {
|
||||
JSON.parse(val);
|
||||
@@ -72,25 +60,10 @@ export class DesktopCredentialStorageListener {
|
||||
}
|
||||
|
||||
private async setPassword(serviceName: string, key: string, value: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
const valueObj = JSON.parse(value) as BiometricKey;
|
||||
await this.biometricService.setEncryptionKeyHalf({
|
||||
service: serviceName,
|
||||
key,
|
||||
value: valueObj?.clientEncKeyHalf,
|
||||
});
|
||||
// Value is usually a JSON string, but we need to pass the key half as well, so we re-stringify key here.
|
||||
await this.biometricService.setBiometricKey(serviceName, key, JSON.stringify(valueObj?.key));
|
||||
} else {
|
||||
await passwords.setPassword(serviceName, key, value);
|
||||
}
|
||||
await passwords.setPassword(serviceName, key, value);
|
||||
}
|
||||
|
||||
private async deletePassword(serviceName: string, key: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
await this.biometricService.deleteBiometricKey(serviceName, key);
|
||||
} else {
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ const nativeMessaging = {
|
||||
},
|
||||
sendMessage: (message: {
|
||||
appId: string;
|
||||
messageId?: number;
|
||||
command?: string;
|
||||
sharedSecret?: string;
|
||||
message?: EncString;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { makeEncString } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../../libs/common/spec/fake-account-service";
|
||||
|
||||
import { ElectronKeyService } from "./electron-key.service";
|
||||
|
||||
describe("electronKeyService", () => {
|
||||
let sut: ElectronKeyService;
|
||||
|
||||
const pinService = mock<PinServiceAbstraction>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const platformUtilService = mock<PlatformUtilsService>();
|
||||
const logService = mock<LogService>();
|
||||
const stateService = mock<StateService>();
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
|
||||
const mockUserId = "mock user id" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith("userId" as UserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new ElectronKeyService(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilService,
|
||||
logService,
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
biometricStateService,
|
||||
kdfConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("setUserKey", () => {
|
||||
let mockUserKey: UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
});
|
||||
|
||||
describe("Biometric Key refresh", () => {
|
||||
const encClientKeyHalf = makeEncString();
|
||||
const decClientKeyHalf = "decrypted client key half";
|
||||
|
||||
beforeEach(() => {
|
||||
encClientKeyHalf.decrypt = jest.fn().mockResolvedValue(decClientKeyHalf);
|
||||
});
|
||||
|
||||
it("sets a Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
platformUtilService.supportsSecureStorage.mockReturnValue(true);
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
|
||||
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(encClientKeyHalf);
|
||||
|
||||
await sut.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: expect.any(String), clientEncKeyHalf: decClientKeyHalf }),
|
||||
{
|
||||
userId: mockUserId,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the Biometric key if getBiometricUnlock is false or the platform does not support secure storage", async () => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
platformUtilService.supportsSecureStorage.mockReturnValue(false);
|
||||
|
||||
await sut.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(null, {
|
||||
userId: mockUserId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
@@ -13,7 +11,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CsprngString } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -24,6 +21,8 @@ import {
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopBiometricsService } from "src/key-management/biometrics/desktop.biometrics.service";
|
||||
|
||||
export class ElectronKeyService extends DefaultKeyService {
|
||||
constructor(
|
||||
pinService: PinServiceAbstraction,
|
||||
@@ -38,6 +37,7 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
stateProvider: StateProvider,
|
||||
private biometricStateService: BiometricStateService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
) {
|
||||
super(
|
||||
pinService,
|
||||
@@ -55,19 +55,10 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
}
|
||||
|
||||
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
return await this.stateService.hasUserKeyBiometric({ userId: userId });
|
||||
}
|
||||
return super.hasUserKeyStored(keySuffix, userId);
|
||||
}
|
||||
|
||||
override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
await this.stateService.setUserKeyBiometric(null, { userId: userId });
|
||||
await this.biometricStateService.removeEncryptedClientKeyHalf(userId);
|
||||
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
await super.clearStoredUserKey(keySuffix, userId);
|
||||
@@ -76,52 +67,35 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
protected override async storeAdditionalKeys(key: UserKey, userId: UserId) {
|
||||
await super.storeAdditionalKeys(key, userId);
|
||||
|
||||
const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId);
|
||||
|
||||
if (storeBiometricKey) {
|
||||
await this.storeBiometricKey(key, userId);
|
||||
} else {
|
||||
await this.stateService.setUserKeyBiometric(null, { userId: userId });
|
||||
if (await this.biometricStateService.getBiometricUnlockEnabled(userId)) {
|
||||
await this.storeBiometricsProtectedUserKey(key, userId);
|
||||
}
|
||||
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
|
||||
}
|
||||
|
||||
protected override async getKeyFromStorage(
|
||||
keySuffix: KeySuffixOptions,
|
||||
userId?: UserId,
|
||||
): Promise<UserKey> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const userKey = await this.stateService.getUserKeyBiometric({ userId: userId });
|
||||
return userKey == null
|
||||
? null
|
||||
: (new SymmetricCryptoKey(Utils.fromB64ToArray(userKey)) as UserKey);
|
||||
}
|
||||
return await super.getKeyFromStorage(keySuffix, userId);
|
||||
}
|
||||
|
||||
protected async storeBiometricKey(key: UserKey, userId?: UserId): Promise<void> {
|
||||
protected async storeBiometricsProtectedUserKey(
|
||||
userKey: UserKey,
|
||||
userId?: UserId,
|
||||
): Promise<void> {
|
||||
// May resolve to null, in which case no client key have is required
|
||||
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(key, userId);
|
||||
await this.stateService.setUserKeyBiometric(
|
||||
{ key: key.keyB64, clientEncKeyHalf },
|
||||
{ userId: userId },
|
||||
);
|
||||
// TODO: Move to windows implementation
|
||||
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId);
|
||||
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf);
|
||||
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64);
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId);
|
||||
const biometricUnlock = await biometricUnlockPromise;
|
||||
return biometricUnlock && this.platformUtilService.supportsSecureStorage();
|
||||
}
|
||||
return await super.shouldStoreKey(keySuffix, userId);
|
||||
}
|
||||
|
||||
protected override async clearAllStoredUserKeys(userId?: UserId): Promise<void> {
|
||||
await this.clearStoredUserKey(KeySuffixOptions.Biometric, userId);
|
||||
await this.biometricService.deleteBiometricUnlockKeyForUser(userId);
|
||||
await super.clearAllStoredUserKeys(userId);
|
||||
}
|
||||
|
||||
@@ -135,18 +109,18 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
}
|
||||
|
||||
// Retrieve existing key half if it exists
|
||||
let biometricKey = await this.biometricStateService
|
||||
let clientKeyHalf = await this.biometricStateService
|
||||
.getEncryptedClientKeyHalf(userId)
|
||||
.then((result) => result?.decrypt(null /* user encrypted */, userKey))
|
||||
.then((result) => result as CsprngString);
|
||||
if (biometricKey == null && userKey != null) {
|
||||
if (clientKeyHalf == null && userKey != null) {
|
||||
// Set a key half if it doesn't exist
|
||||
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
|
||||
clientKeyHalf = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(clientKeyHalf, userKey);
|
||||
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
|
||||
}
|
||||
|
||||
return biometricKey;
|
||||
return clientKeyHalf;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
import { BiometricMessageHandlerService } from "./biometric-message-handler.service";
|
||||
|
||||
(global as any).ipc = {
|
||||
platform: {
|
||||
reloadProcess: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const accounts = {
|
||||
[SomeUser]: {
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[AnotherUser]: {
|
||||
name: "some other user",
|
||||
email: "some.other.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("BiometricMessageHandlerService", () => {
|
||||
let service: BiometricMessageHandlerService;
|
||||
|
||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let desktopSettingsService: DesktopSettingsService;
|
||||
let biometricStateService: BiometricStateService;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let accountService: AccountService;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let ngZone: MockProxy<NgZone>;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
logService = mock<LogService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
desktopSettingsService = mock<DesktopSettingsService>();
|
||||
biometricStateService = mock<BiometricStateService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
accountService = new FakeAccountService(accounts);
|
||||
authService = mock<AuthService>();
|
||||
ngZone = mock<NgZone>();
|
||||
|
||||
service = new BiometricMessageHandlerService(
|
||||
cryptoFunctionService,
|
||||
keyService,
|
||||
encryptService,
|
||||
logService,
|
||||
messagingService,
|
||||
desktopSettingsService,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
dialogService,
|
||||
accountService,
|
||||
authService,
|
||||
ngZone,
|
||||
);
|
||||
});
|
||||
|
||||
describe("process reload", () => {
|
||||
const testCases = [
|
||||
// don't reload when the active user is the requested one and unlocked
|
||||
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, false, false],
|
||||
// do reload when the active user is the requested one but locked
|
||||
[SomeUser, AuthenticationStatus.Locked, SomeUser, false, true],
|
||||
// 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 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],
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"process reload for active user %s with auth status %s and other user %s and isdev: %s should process reload: %s",
|
||||
async (activeUser, authStatus, messageUser, isDev, shouldReload) => {
|
||||
await accountService.switchAccount(activeUser as UserId);
|
||||
authService.authStatusFor$.mockReturnValue(of(authStatus as AuthenticationStatus));
|
||||
(global as any).ipc.platform.isDev = isDev;
|
||||
(global as any).ipc.platform.reloadProcess.mockClear();
|
||||
await service.processReloadWhenRequired(messageUser as UserId);
|
||||
|
||||
if (shouldReload) {
|
||||
expect((global as any).ipc.platform.reloadProcess).toHaveBeenCalled();
|
||||
} else {
|
||||
expect((global as any).ipc.platform.reloadProcess).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,18 @@ import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/c
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricStateService,
|
||||
BiometricsCommands,
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
|
||||
import { LegacyMessage } from "../models/native-messaging/legacy-message";
|
||||
@@ -54,6 +59,9 @@ export class BiometricMessageHandlerService {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const userIds = Object.keys(accounts);
|
||||
if (!userIds.includes(rawMessage.userId)) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Received message for user that is not logged into the desktop app.",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "wrongUserId",
|
||||
appId: appId,
|
||||
@@ -62,6 +70,7 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) {
|
||||
this.logService.info("[Native Messaging IPC] Requesting fingerprint verification.");
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "verifyFingerprint",
|
||||
appId: appId,
|
||||
@@ -81,6 +90,7 @@ export class BiometricMessageHandlerService {
|
||||
const browserSyncVerified = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (browserSyncVerified !== true) {
|
||||
this.logService.info("[Native Messaging IPC] Fingerprint verification failed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +100,9 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Epheremal secret for secure channel is missing. Invalidating encryption...",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "invalidateEncryption",
|
||||
appId: appId,
|
||||
@@ -106,6 +119,9 @@ export class BiometricMessageHandlerService {
|
||||
|
||||
// Shared secret is invalidated, force re-authentication
|
||||
if (message == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Secure channel failed to decrypt message. Invalidating encryption...",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "invalidateEncryption",
|
||||
appId: appId,
|
||||
@@ -114,20 +130,86 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
this.logService.error("NativeMessage is to old, ignoring.");
|
||||
this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = message.messageId;
|
||||
|
||||
switch (message.command) {
|
||||
case "biometricUnlock": {
|
||||
case BiometricsCommands.UnlockWithBiometricsForUser: {
|
||||
await this.handleUnlockWithBiometricsForUser(message, messageId, appId);
|
||||
break;
|
||||
}
|
||||
case BiometricsCommands.AuthenticateWithBiometrics: {
|
||||
try {
|
||||
const unlocked = await this.biometricsService.authenticateWithBiometrics();
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.AuthenticateWithBiometrics,
|
||||
messageId,
|
||||
response: unlocked,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error("[Native Messaging IPC] Biometric authentication failed", e);
|
||||
await this.send(
|
||||
{ command: BiometricsCommands.AuthenticateWithBiometrics, messageId, response: false },
|
||||
appId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatus: {
|
||||
const status = await this.biometricsService.getBiometricsStatus();
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.GetBiometricsStatus,
|
||||
messageId,
|
||||
response: status,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatusForUser: {
|
||||
let status = await this.biometricsService.getBiometricsStatusForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
if (status == BiometricsStatus.NotEnabledLocally) {
|
||||
status = BiometricsStatus.NotEnabledInConnectedDesktopApp;
|
||||
}
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.GetBiometricsStatusForUser,
|
||||
messageId,
|
||||
response: status,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
// TODO: legacy, remove after 2025.01
|
||||
case BiometricsCommands.IsAvailable: {
|
||||
const available =
|
||||
(await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available;
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
response: available ? "available" : "not available",
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
// TODO: legacy, remove after 2025.01
|
||||
case BiometricsCommands.Unlock: {
|
||||
const isTemporarilyDisabled =
|
||||
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
|
||||
!(await this.biometricsService.supportsBiometric());
|
||||
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
|
||||
if (isTemporarilyDisabled) {
|
||||
return this.send({ command: "biometricUnlock", response: "not available" }, appId);
|
||||
}
|
||||
|
||||
if (!(await this.biometricsService.supportsBiometric())) {
|
||||
if (!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available)) {
|
||||
return this.send({ command: "biometricUnlock", response: "not supported" }, appId);
|
||||
}
|
||||
|
||||
@@ -158,10 +240,7 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
try {
|
||||
const userKey = await this.keyService.getUserKeyFromStorage(
|
||||
KeySuffixOptions.Biometric,
|
||||
message.userId,
|
||||
);
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
|
||||
|
||||
if (userKey != null) {
|
||||
await this.send(
|
||||
@@ -189,19 +268,8 @@ export class BiometricMessageHandlerService {
|
||||
} catch (e) {
|
||||
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
const isAvailable = await this.biometricsService.supportsBiometric();
|
||||
return this.send(
|
||||
{
|
||||
command: "biometricUnlockAvailable",
|
||||
response: isAvailable ? "available" : "not available",
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
default:
|
||||
this.logService.error("NativeMessage, got unknown command: " + message.command);
|
||||
break;
|
||||
@@ -216,7 +284,11 @@ export class BiometricMessageHandlerService {
|
||||
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
|
||||
);
|
||||
|
||||
ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted });
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
appId: appId,
|
||||
messageId: message.messageId,
|
||||
message: encrypted,
|
||||
});
|
||||
}
|
||||
|
||||
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
|
||||
@@ -226,6 +298,7 @@ export class BiometricMessageHandlerService {
|
||||
new SymmetricCryptoKey(secret).keyB64,
|
||||
);
|
||||
|
||||
this.logService.info("[Native Messaging IPC] Setting up secure channel");
|
||||
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
|
||||
secret,
|
||||
remotePublicKey,
|
||||
@@ -234,7 +307,62 @@ export class BiometricMessageHandlerService {
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
appId: appId,
|
||||
command: "setupEncryption",
|
||||
messageId: -1, // to indicate to the other side that this is a new desktop client. refactor later to use proper versioning
|
||||
sharedSecret: Utils.fromBufferToB64(encryptedSecret),
|
||||
});
|
||||
}
|
||||
|
||||
private async handleUnlockWithBiometricsForUser(
|
||||
message: LegacyMessage,
|
||||
messageId: number,
|
||||
appId: string,
|
||||
) {
|
||||
const messageUserId = message.userId as UserId;
|
||||
try {
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
|
||||
if (userKey != null) {
|
||||
this.logService.info("[Native Messaging IPC] Biometric unlock for user: " + messageUserId);
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
response: true,
|
||||
messageId,
|
||||
userKeyB64: userKey.keyB64,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
await this.processReloadWhenRequired(messageUserId);
|
||||
} else {
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
messageId,
|
||||
response: false,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
await this.send(
|
||||
{ command: BiometricsCommands.UnlockWithBiometricsForUser, messageId, response: false },
|
||||
appId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 isCurrentlyActiveAccountUnlocked =
|
||||
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
|
||||
AuthenticationStatus.Unlocked;
|
||||
|
||||
if (currentlyActiveAccountId !== messageUserId || !isCurrentlyActiveAccountUnlocked) {
|
||||
if (!ipc.platform.isDev) {
|
||||
ipc.platform.reloadProcess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
export enum BiometricAction {
|
||||
EnabledForUser = "enabled",
|
||||
OsSupported = "osSupported",
|
||||
Authenticate = "authenticate",
|
||||
NeedsSetup = "needsSetup",
|
||||
GetStatus = "status",
|
||||
|
||||
UnlockForUser = "unlockForUser",
|
||||
GetStatusForUser = "statusForUser",
|
||||
SetKeyForUser = "setKeyForUser",
|
||||
RemoveKeyForUser = "removeKeyForUser",
|
||||
|
||||
SetClientKeyHalf = "setClientKeyHalf",
|
||||
|
||||
Setup = "setup",
|
||||
CanAutoSetup = "canAutoSetup",
|
||||
|
||||
GetShouldAutoprompt = "getShouldAutoprompt",
|
||||
SetShouldAutoprompt = "setShouldAutoprompt",
|
||||
}
|
||||
|
||||
export type BiometricMessage = {
|
||||
action: BiometricAction;
|
||||
keySuffix?: string;
|
||||
key?: string;
|
||||
userId?: string;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { WebLockComponentService } from "./web-lock-component.service";
|
||||
|
||||
@@ -86,7 +87,7 @@ describe("WebLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
export class WebLockComponentService implements LockComponentService {
|
||||
@@ -45,7 +46,7 @@ export class WebLockComponentService implements LockComponentService {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
export class WebBiometricsService extends BiometricsService {
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user