mirror of
https://github.com/bitwarden/browser
synced 2026-01-07 19:13:39 +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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user