mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
Rework Desktop Biometrics (#5234)
This commit is contained in:
@@ -108,9 +108,12 @@
|
||||
{{ biometricText | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="help-block" *ngIf="this.form.value.biometric">{{
|
||||
additionalBiometricSettingsText | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<div class="checkbox">
|
||||
<div class="checkbox form-group-child">
|
||||
<label for="autoPromptBiometrics">
|
||||
<input
|
||||
id="autoPromptBiometrics"
|
||||
@@ -122,6 +125,22 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<div class="checkbox form-group-child">
|
||||
<label for="requirePasswordOnStart">
|
||||
<input
|
||||
id="requirePasswordOnStart"
|
||||
type="checkbox"
|
||||
formControlName="requirePasswordOnStart"
|
||||
(change)="updateRequirePasswordOnStart()"
|
||||
/>
|
||||
{{ "requirePasswordOnStart" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small class="help-block form-group-child" *ngIf="isWindows">{{
|
||||
"recommendedForSecurity" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="checkbox">
|
||||
<label for="approveLoginRequests">
|
||||
|
||||
@@ -9,15 +9,15 @@ import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { DeviceType, ThemeType, StorageLocation } from "@bitwarden/common/enums";
|
||||
import { DeviceType, ThemeType, StorageLocation, KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
|
||||
import { flagEnabled } from "../../flags";
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { isWindowsStore } from "../../utils";
|
||||
import { SetPinComponent } from "../components/set-pin.component";
|
||||
|
||||
@@ -37,10 +37,12 @@ export class SettingsComponent implements OnInit {
|
||||
clearClipboardOptions: any[];
|
||||
supportsBiometric: boolean;
|
||||
biometricText: string;
|
||||
additionalBiometricSettingsText: string;
|
||||
autoPromptBiometricsText: string;
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
isWindows: boolean;
|
||||
|
||||
enableTrayText: string;
|
||||
enableTrayDescText: string;
|
||||
@@ -70,6 +72,7 @@ export class SettingsComponent implements OnInit {
|
||||
pin: [null as boolean | null],
|
||||
biometric: false,
|
||||
autoPromptBiometrics: false,
|
||||
requirePasswordOnStart: false,
|
||||
approveLoginRequests: false,
|
||||
// Account Preferences
|
||||
clearClipboard: [null as number | null],
|
||||
@@ -100,7 +103,7 @@ export class SettingsComponent implements OnInit {
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private messagingService: MessagingService,
|
||||
private cryptoService: CryptoService,
|
||||
private modalService: ModalService,
|
||||
@@ -182,6 +185,8 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop;
|
||||
|
||||
if ((await this.stateService.getUserId()) == null) {
|
||||
return;
|
||||
}
|
||||
@@ -216,7 +221,9 @@ export class SettingsComponent implements OnInit {
|
||||
vaultTimeoutAction: await this.vaultTimeoutSettingsService.getVaultTimeoutAction(),
|
||||
pin: pinSet[0] || pinSet[1],
|
||||
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
|
||||
autoPromptBiometrics: !(await this.stateService.getNoAutoPromptBiometrics()),
|
||||
autoPromptBiometrics: !(await this.stateService.getDisableAutoBiometricsPrompt()),
|
||||
requirePasswordOnStart:
|
||||
(await this.stateService.getBiometricRequirePasswordOnStart()) ?? false,
|
||||
approveLoginRequests: (await this.stateService.getApproveLoginRequests()) ?? false,
|
||||
clearClipboard: await this.stateService.getClearClipboard(),
|
||||
minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(),
|
||||
@@ -246,6 +253,10 @@ export class SettingsComponent implements OnInit {
|
||||
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
this.supportsBiometric = await this.platformUtilsService.supportsBiometric();
|
||||
this.biometricText = await this.stateService.getBiometricText();
|
||||
this.additionalBiometricSettingsText =
|
||||
this.biometricText === "unlockWithTouchId"
|
||||
? "additionalTouchIdSettings"
|
||||
: "additionalWindowsHelloSettings";
|
||||
this.autoPromptBiometricsText = await this.stateService.getNoAutoPromptBiometricsText();
|
||||
this.previousVaultTimeout = this.form.value.vaultTimeout;
|
||||
|
||||
@@ -379,26 +390,52 @@ export class SettingsComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await this.platformUtilsService.authenticateBiometric();
|
||||
|
||||
if (!authResult) {
|
||||
this.form.controls.biometric.setValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.controls.biometric.setValue(true);
|
||||
await this.stateService.setBiometricUnlock(true);
|
||||
if (this.isWindows) {
|
||||
// Recommended settings for Windows Hello
|
||||
this.form.controls.requirePasswordOnStart.setValue(true);
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(true);
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
}
|
||||
await this.cryptoService.toggleKey();
|
||||
|
||||
// Validate the key is stored in case biometrics fail.
|
||||
const biometricSet = await this.cryptoService.hasKeyStored(KeySuffixOptions.Biometric);
|
||||
this.form.controls.biometric.setValue(biometricSet);
|
||||
if (!biometricSet) {
|
||||
await this.stateService.setBiometricUnlock(null);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutoPromptBiometrics() {
|
||||
if (this.form.value.autoPromptBiometrics) {
|
||||
await this.stateService.setNoAutoPromptBiometrics(null);
|
||||
// require password on start must be disabled if auto prompt biometrics is enabled
|
||||
this.form.controls.requirePasswordOnStart.setValue(false);
|
||||
await this.updateRequirePasswordOnStart();
|
||||
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(null);
|
||||
} else {
|
||||
await this.stateService.setNoAutoPromptBiometrics(true);
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRequirePasswordOnStart() {
|
||||
if (this.form.value.requirePasswordOnStart) {
|
||||
// auto prompt biometrics must be disabled if require password on start is enabled
|
||||
this.form.controls.autoPromptBiometrics.setValue(false);
|
||||
await this.updateAutoPromptBiometrics();
|
||||
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(true);
|
||||
} else {
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(false);
|
||||
await this.stateService.setBiometricEncryptionClientKeyHalf(null);
|
||||
}
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
await this.cryptoService.toggleKey();
|
||||
}
|
||||
|
||||
async saveFavicons() {
|
||||
await this.stateService.setDisableFavicon(!this.form.value.enableFavicons);
|
||||
await this.stateService.setDisableFavicon(!this.form.value.enableFavicons, {
|
||||
|
||||
@@ -48,11 +48,12 @@ import { ElectronPlatformUtilsService } from "../../services/electron-platform-u
|
||||
import { ElectronRendererMessagingService } from "../../services/electron-renderer-messaging.service";
|
||||
import { ElectronRendererSecureStorageService } from "../../services/electron-renderer-secure-storage.service";
|
||||
import { ElectronRendererStorageService } from "../../services/electron-renderer-storage.service";
|
||||
import { ElectronStateService } from "../../services/electron-state.service";
|
||||
import { ElectronStateService as ElectronStateServiceAbstraction } from "../../services/electron-state.service.abstraction";
|
||||
import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service";
|
||||
import { I18nService } from "../../services/i18n.service";
|
||||
import { NativeMessageHandlerService } from "../../services/native-message-handler.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
import { StateService } from "../../services/state.service";
|
||||
import { PasswordRepromptService } from "../../vault/services/password-reprompt.service";
|
||||
import { SearchBarService } from "../layout/search/search-bar.service";
|
||||
|
||||
@@ -112,17 +113,6 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
{ provide: AbstractStorageService, useClass: ElectronRendererStorageService },
|
||||
{ provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService },
|
||||
{ provide: MEMORY_STORAGE, useClass: MemoryStorageService },
|
||||
{
|
||||
provide: CryptoServiceAbstraction,
|
||||
useClass: ElectronCryptoService,
|
||||
deps: [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
LogServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: SystemServiceAbstraction,
|
||||
useClass: SystemService,
|
||||
@@ -136,7 +126,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
{ provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService },
|
||||
{
|
||||
provide: StateServiceAbstraction,
|
||||
useClass: StateService,
|
||||
useClass: ElectronStateService,
|
||||
deps: [
|
||||
AbstractStorageService,
|
||||
SECURE_STORAGE,
|
||||
@@ -147,6 +137,10 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
STATE_SERVICE_USE_CACHE,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: ElectronStateServiceAbstraction,
|
||||
useExisting: StateServiceAbstraction,
|
||||
},
|
||||
{
|
||||
provide: FileDownloadService,
|
||||
useClass: DesktopFileDownloadService,
|
||||
@@ -182,6 +176,17 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
useClass: LoginService,
|
||||
deps: [StateServiceAbstraction],
|
||||
},
|
||||
{
|
||||
provide: CryptoServiceAbstraction,
|
||||
useClass: ElectronCryptoService,
|
||||
deps: [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
EncryptService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
LogService,
|
||||
StateServiceAbstraction,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons with-rows">
|
||||
<div class="buttons-row" *ngIf="supportsBiometric && biometricLock">
|
||||
<div class="buttons-row" *ngIf="supportsBiometric && biometricLock && biometricReady">
|
||||
<button
|
||||
type="button"
|
||||
class="btn block"
|
||||
|
||||
@@ -13,14 +13,17 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service";
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { DeviceType, KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
|
||||
import { ElectronStateService } from "../services/electron-state.service.abstraction";
|
||||
import { BiometricStorageAction, BiometricMessage } from "../types/biometric-message";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
@Component({
|
||||
@@ -29,6 +32,7 @@ const BroadcasterSubscriptionId = "LockComponent";
|
||||
})
|
||||
export class LockComponent extends BaseLockComponent {
|
||||
private deferFocus: boolean = null;
|
||||
protected biometricReady = false;
|
||||
protected oldOs = false;
|
||||
protected deprecated = false;
|
||||
|
||||
@@ -41,7 +45,7 @@ export class LockComponent extends BaseLockComponent {
|
||||
vaultTimeoutService: VaultTimeoutService,
|
||||
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
environmentService: EnvironmentService,
|
||||
stateService: StateService,
|
||||
protected override stateService: ElectronStateService,
|
||||
apiService: ApiService,
|
||||
private route: ActivatedRoute,
|
||||
private broadcasterService: BroadcasterService,
|
||||
@@ -88,7 +92,10 @@ export class LockComponent extends BaseLockComponent {
|
||||
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
const autoPromptBiometric = !(await this.stateService.getNoAutoPromptBiometrics());
|
||||
const autoPromptBiometric = !(await this.stateService.getDisableAutoBiometricsPrompt());
|
||||
this.biometricReady = await this.canUseBiometric();
|
||||
|
||||
await this.displayBiometricUpdateWarning();
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
@@ -135,7 +142,44 @@ export class LockComponent extends BaseLockComponent {
|
||||
this.showPassword = false;
|
||||
}
|
||||
|
||||
private async canUseBiometric() {
|
||||
const userId = await this.stateService.getUserId();
|
||||
const val = await ipcRenderer.invoke("biometric", {
|
||||
action: BiometricStorageAction.EnabledForUser,
|
||||
key: `${userId}_masterkey_biometric`,
|
||||
keySuffix: KeySuffixOptions.Biometric,
|
||||
userId: userId,
|
||||
} as BiometricMessage);
|
||||
return val != null ? (JSON.parse(val) as boolean) : null;
|
||||
}
|
||||
|
||||
private focusInput() {
|
||||
document.getElementById(this.pinLock ? "pin" : "masterPassword").focus();
|
||||
}
|
||||
|
||||
private async displayBiometricUpdateWarning(): Promise<void> {
|
||||
if (await this.stateService.getDismissedBiometricRequirePasswordOnStart()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.platformUtilsService.getDevice() !== DeviceType.WindowsDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.stateService.getBiometricUnlock()) {
|
||||
const response = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("windowsBiometricUpdateWarning"),
|
||||
this.i18nService.t("windowsBiometricUpdateWarningTitle"),
|
||||
this.i18nService.t("yes"),
|
||||
this.i18nService.t("no")
|
||||
);
|
||||
|
||||
await this.stateService.setBiometricRequirePasswordOnStart(response);
|
||||
if (response) {
|
||||
await this.stateService.setDisableAutoBiometricsPrompt(true);
|
||||
}
|
||||
this.supportsBiometric = await this.canUseBiometric();
|
||||
await this.stateService.setDismissedBiometricRequirePasswordOnStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,20 +1392,32 @@
|
||||
"unlockWithWindowsHello": {
|
||||
"message": "Unlock with Windows Hello"
|
||||
},
|
||||
"additionalWindowsHelloSettings": {
|
||||
"message": "Additional Windows Hello settings"
|
||||
},
|
||||
"windowsHelloConsentMessage": {
|
||||
"message": "Verify for Bitwarden."
|
||||
},
|
||||
"unlockWithTouchId": {
|
||||
"message": "Unlock with Touch ID"
|
||||
},
|
||||
"additionalTouchIdSettings": {
|
||||
"message": "Additional Touch ID settings"
|
||||
},
|
||||
"touchIdConsentMessage": {
|
||||
"message": "unlock your vault"
|
||||
},
|
||||
"autoPromptWindowsHello": {
|
||||
"message": "Ask for Windows Hello on launch"
|
||||
"message": "Ask for Windows Hello on app start"
|
||||
},
|
||||
"autoPromptTouchId": {
|
||||
"message": "Ask for Touch ID on launch"
|
||||
"message": "Ask for Touch ID on app start"
|
||||
},
|
||||
"requirePasswordOnStart": {
|
||||
"message": "Require password or PIN on app start"
|
||||
},
|
||||
"recommendedForSecurity": {
|
||||
"message": "Recommended for security."
|
||||
},
|
||||
"lockWithMasterPassOnRestart": {
|
||||
"message": "Lock with master password on restart"
|
||||
@@ -2234,6 +2246,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowsBiometricUpdateWarning": {
|
||||
"message": "Bitwarden recommends updating your biometric settings to require your master password (or PIN) on the first unlock. Would you like to update your settings now?"
|
||||
},
|
||||
"windowsBiometricUpdateWarningTitle": {
|
||||
"message": "Recommended Settings Update"
|
||||
},
|
||||
"windows8SoonDeprecated": {
|
||||
"message": "The operating system you are using will no longer be supported after the 2023.5.0 release. Upgrade to a supported operating system. Continuing without updating your operating system may result in unexpected behavior or security risks.",
|
||||
"description": "Windows 8, 8.1 and Server 2012 R2 are no longer supported by Electron & Chromium. Show a notice on the login and lock screen while 2023.4.0 is the active version."
|
||||
|
||||
@@ -5,7 +5,6 @@ import { app } from "electron";
|
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
||||
import { StateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { BiometricsService, BiometricsServiceAbstraction } from "./main/biometric/index";
|
||||
import { DesktopCredentialStorageListener } from "./main/desktop-credential-storage-listener";
|
||||
@@ -19,6 +18,7 @@ import { WindowMain } from "./main/window.main";
|
||||
import { Account } from "./models/account";
|
||||
import { ElectronLogService } from "./services/electron-log.service";
|
||||
import { ElectronMainMessagingService } from "./services/electron-main-messaging.service";
|
||||
import { ElectronStateService } from "./services/electron-state.service";
|
||||
import { ElectronStorageService } from "./services/electron-storage.service";
|
||||
import { I18nService } from "./services/i18n.service";
|
||||
|
||||
@@ -28,7 +28,7 @@ export class Main {
|
||||
storageService: ElectronStorageService;
|
||||
memoryStorageService: MemoryStorageService;
|
||||
messagingService: ElectronMainMessagingService;
|
||||
stateService: StateService;
|
||||
stateService: ElectronStateService;
|
||||
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
||||
|
||||
windowMain: WindowMain;
|
||||
@@ -85,7 +85,7 @@ export class Main {
|
||||
// TODO: this state service will have access to on disk storage, but not in memory storage.
|
||||
// If we could get this to work using the stateService singleton that the rest of the app uses we could save
|
||||
// ourselves from some hacks, like having to manually update the app menu vs. the menu subscribing to events.
|
||||
this.stateService = new StateService(
|
||||
this.stateService = new ElectronStateService(
|
||||
this.storageService,
|
||||
null,
|
||||
this.memoryStorageService,
|
||||
@@ -128,7 +128,8 @@ export class Main {
|
||||
|
||||
this.desktopCredentialStorageListener = new DesktopCredentialStorageListener(
|
||||
"Bitwarden",
|
||||
this.biometricsService
|
||||
this.biometricsService,
|
||||
this.logService
|
||||
);
|
||||
|
||||
this.nativeMessagingMain = new NativeMessagingMain(
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { ipcMain, systemPreferences } from "electron";
|
||||
import { systemPreferences } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
export default class BiometricDarwinMain implements BiometricsServiceAbstraction {
|
||||
export default class BiometricDarwinMain implements OsBiometricService {
|
||||
constructor(private i18nservice: I18nService, private stateService: StateService) {}
|
||||
|
||||
async init() {
|
||||
await this.stateService.setEnableBiometric(await this.supportsBiometric());
|
||||
await this.stateService.setBiometricText("unlockWithTouchId");
|
||||
await this.stateService.setNoAutoPromptBiometricsText("autoPromptTouchId");
|
||||
|
||||
ipcMain.handle("biometric", async () => {
|
||||
return await this.authenticateBiometric();
|
||||
});
|
||||
}
|
||||
|
||||
supportsBiometric(): Promise<boolean> {
|
||||
return Promise.resolve(systemPreferences.canPromptTouchID());
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return systemPreferences.canPromptTouchID();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
@@ -30,4 +26,35 @@ export default class BiometricDarwinMain implements BiometricsServiceAbstraction
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getBiometricKey(service: string, key: string): Promise<string | null> {
|
||||
const success = await this.authenticateBiometric();
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Biometric authentication failed");
|
||||
}
|
||||
|
||||
return await passwords.getPassword(service, key);
|
||||
}
|
||||
|
||||
async setBiometricKey(service: string, key: string, value: string): Promise<void> {
|
||||
if (await this.valueUpToDate(service, key, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await passwords.setPassword(service, key, value);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {
|
||||
return await passwords.deletePassword(service, key);
|
||||
}
|
||||
|
||||
private async valueUpToDate(service: string, key: string, value: string): Promise<boolean> {
|
||||
try {
|
||||
const existing = await passwords.getPassword(service, key);
|
||||
return existing === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,224 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { biometrics } from "@bitwarden/desktop-native";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { biometrics, passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
const KEY_WITNESS_SUFFIX = "_witness";
|
||||
const WITNESS_VALUE = "known key";
|
||||
|
||||
export default class BiometricWindowsMain implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
private _osKeyHalf: string | null = null;
|
||||
|
||||
export default class BiometricWindowsMain implements BiometricsServiceAbstraction {
|
||||
constructor(
|
||||
private i18nservice: I18nService,
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private logService: LogService
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
let supportsBiometric = false;
|
||||
try {
|
||||
supportsBiometric = await this.supportsBiometric();
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
await this.stateService.setEnableBiometric(supportsBiometric);
|
||||
await this.stateService.setBiometricText("unlockWithWindowsHello");
|
||||
await this.stateService.setNoAutoPromptBiometricsText("autoPromptWindowsHello");
|
||||
|
||||
ipcMain.handle("biometric", async () => {
|
||||
return await this.authenticateBiometric();
|
||||
});
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
try {
|
||||
return await biometrics.available();
|
||||
} catch {
|
||||
return false;
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return await biometrics.available();
|
||||
}
|
||||
|
||||
async getBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyHalfB64: string
|
||||
): Promise<string | null> {
|
||||
const value = await passwords.getPassword(service, storageKey);
|
||||
|
||||
if (value == null || value == "") {
|
||||
return null;
|
||||
} else if (!EncString.isSerializedEncString(value)) {
|
||||
// Update to format encrypted with client key half
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
});
|
||||
|
||||
await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
return value;
|
||||
} else {
|
||||
const encValue = new EncString(value);
|
||||
this.setIv(encValue.iv);
|
||||
const storageDetails = await this.getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
});
|
||||
return await biometrics.getBiometricSecret(service, storageKey, storageDetails.key_material);
|
||||
}
|
||||
}
|
||||
|
||||
async setBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
value: string,
|
||||
clientKeyPartB64: string | undefined
|
||||
): Promise<void> {
|
||||
const parsedValue = SymmetricCryptoKey.fromString(value);
|
||||
if (await this.valueUpToDate({ value: parsedValue, clientKeyPartB64, service, storageKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
|
||||
const storedValue = await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
storageDetails.key_material,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
const parsedStoredValue = new EncString(storedValue);
|
||||
await this.storeValueWitness(
|
||||
parsedValue,
|
||||
parsedStoredValue,
|
||||
service,
|
||||
storageKey,
|
||||
clientKeyPartB64
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {
|
||||
await passwords.deletePassword(service, key);
|
||||
await passwords.deletePassword(service, key + KEY_WITNESS_SUFFIX);
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const hwnd = this.windowMain.win.getNativeWindowHandle();
|
||||
return await biometrics.prompt(hwnd, this.i18nservice.t("windowsHelloConsentMessage"));
|
||||
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
|
||||
}
|
||||
|
||||
private async getStorageDetails({
|
||||
clientKeyHalfB64,
|
||||
}: {
|
||||
clientKeyHalfB64: string;
|
||||
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
|
||||
if (this._osKeyHalf == null) {
|
||||
// Prompts Windows Hello
|
||||
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
|
||||
this._osKeyHalf = keyMaterial.keyB64;
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
return {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
// when we want to force a re-derive of the key material.
|
||||
private setIv(iv: string) {
|
||||
this._iv = iv;
|
||||
this._osKeyHalf = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date.
|
||||
*
|
||||
* @param unencryptedValue The key to store
|
||||
* @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key.
|
||||
* @param service The service to store the witness key under
|
||||
* @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
|
||||
* @returns
|
||||
*/
|
||||
private async storeValueWitness(
|
||||
unencryptedValue: SymmetricCryptoKey,
|
||||
encryptedValue: EncString,
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyPartB64: string
|
||||
) {
|
||||
if (encryptedValue.iv == null || encryptedValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageDetails = {
|
||||
keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64),
|
||||
ivB64: encryptedValue.iv,
|
||||
};
|
||||
await biometrics.setBiometricSecret(
|
||||
service,
|
||||
storageKey + KEY_WITNESS_SUFFIX,
|
||||
WITNESS_VALUE,
|
||||
storageDetails.keyMaterial,
|
||||
storageDetails.ivB64
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
|
||||
* @param value The value being validated
|
||||
* @param service The service the value is stored under
|
||||
* @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
|
||||
* @returns Boolean indicating if the value is up to date.
|
||||
*/
|
||||
// Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
|
||||
private async valueUpToDate({
|
||||
value,
|
||||
clientKeyPartB64,
|
||||
service,
|
||||
storageKey,
|
||||
}: {
|
||||
value: SymmetricCryptoKey;
|
||||
clientKeyPartB64: string;
|
||||
service: string;
|
||||
storageKey: string;
|
||||
}): Promise<boolean> {
|
||||
const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64);
|
||||
if (witnessKeyMaterial == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let witness = null;
|
||||
try {
|
||||
witness = await biometrics.getBiometricSecret(
|
||||
service,
|
||||
storageKey + KEY_WITNESS_SUFFIX,
|
||||
witnessKeyMaterial
|
||||
);
|
||||
} catch {
|
||||
this.logService.debug("Error retrieving witness key, assuming value is not up to date.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (witness === WITNESS_VALUE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Derives a witness key from a symmetric key being stored for biometric protection */
|
||||
private witnessKeyMaterial(
|
||||
symmetricKey: SymmetricCryptoKey,
|
||||
clientKeyPartB64: string
|
||||
): biometrics.KeyMaterial {
|
||||
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
|
||||
return {
|
||||
osKeyPartB64: key,
|
||||
clientKeyPartB64,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
export abstract class BiometricsServiceAbstraction {
|
||||
init: () => Promise<void>;
|
||||
supportsBiometric: () => Promise<boolean>;
|
||||
osSupportsBiometric: () => Promise<boolean>;
|
||||
canAuthBiometric: ({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}) => Promise<boolean>;
|
||||
authenticateBiometric: () => Promise<boolean>;
|
||||
getBiometricKey: (service: string, key: string) => Promise<string | null>;
|
||||
setBiometricKey: (service: string, key: string, value: string) => Promise<void>;
|
||||
setEncryptionKeyHalf: ({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => void;
|
||||
deleteBiometricKey: (service: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface OsBiometricService {
|
||||
init: () => Promise<void>;
|
||||
osSupportsBiometric: () => Promise<boolean>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import BiometricDarwinMain from "./biometric.darwin.main";
|
||||
import BiometricWindowsMain from "./biometric.windows.main";
|
||||
import { BiometricsService } from "./biometrics.service";
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
jest.mock("@bitwarden/desktop-native", () => {
|
||||
return {
|
||||
@@ -22,11 +22,11 @@ jest.mock("@bitwarden/desktop-native", () => {
|
||||
describe("biometrics tests", function () {
|
||||
const i18nService = mock<I18nService>();
|
||||
const windowMain = mock<WindowMain>();
|
||||
const stateService = mock<StateService>();
|
||||
const stateService = mock<ElectronStateService>();
|
||||
const logService = mock<LogService>();
|
||||
const messagingService = mock<MessagingService>();
|
||||
|
||||
it("Should call the platformspecific methods", () => {
|
||||
it("Should call the platformspecific methods", async () => {
|
||||
const sut = new BiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
@@ -36,13 +36,14 @@ describe("biometrics tests", function () {
|
||||
process.platform
|
||||
);
|
||||
|
||||
const mockService = mock<BiometricsServiceAbstraction>();
|
||||
const mockService = mock<OsBiometricService>();
|
||||
(sut as any).platformSpecificService = mockService;
|
||||
sut.init();
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
expect(mockService.init).toBeCalled();
|
||||
|
||||
sut.supportsBiometric();
|
||||
expect(mockService.supportsBiometric).toBeCalled();
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
expect(mockService.osSupportsBiometric).toBeCalled();
|
||||
|
||||
sut.authenticateBiometric();
|
||||
expect(mockService.authenticateBiometric).toBeCalled();
|
||||
@@ -78,4 +79,50 @@ describe("biometrics tests", function () {
|
||||
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can auth biometric", () => {
|
||||
let sut: BiometricsService;
|
||||
let innerService: MockProxy<OsBiometricService>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
stateService,
|
||||
logService,
|
||||
messagingService,
|
||||
process.platform
|
||||
);
|
||||
|
||||
innerService = mock();
|
||||
(sut as any).platformSpecificService = innerService;
|
||||
sut.init();
|
||||
});
|
||||
|
||||
it("should return false if client key half is required and not provided", async () => {
|
||||
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should call osSupportsBiometric if client key half is provided", async () => {
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
expect(innerService.init).toBeCalled();
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
|
||||
it("should call osSupportBiometric if client key half is not required", async () => {
|
||||
stateService.getBiometricRequirePasswordOnStart.mockResolvedValue(false);
|
||||
innerService.osSupportsBiometric.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId: "test" });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
|
||||
import { ElectronStateService } from "../../services/electron-state.service.abstraction";
|
||||
import { WindowMain } from "../window.main";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometrics.service.abstraction";
|
||||
import { BiometricsServiceAbstraction, OsBiometricService } from "./biometrics.service.abstraction";
|
||||
|
||||
export class BiometricsService implements BiometricsServiceAbstraction {
|
||||
private platformSpecificService: BiometricsServiceAbstraction;
|
||||
private platformSpecificService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform
|
||||
@@ -50,16 +51,121 @@ export class BiometricsService implements BiometricsServiceAbstraction {
|
||||
return await this.platformSpecificService.init();
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await this.platformSpecificService.supportsBiometric();
|
||||
async osSupportsBiometric() {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart({
|
||||
userId,
|
||||
});
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
return clientKeyHalfSatisfied && (await this.osSupportsBiometric());
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
let result = false;
|
||||
this.interruptProcessReload(
|
||||
() => {
|
||||
return this.platformSpecificService.authenticateBiometric();
|
||||
},
|
||||
(response) => {
|
||||
result = response;
|
||||
return !response;
|
||||
}
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
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");
|
||||
const response = await this.platformSpecificService.authenticateBiometric();
|
||||
if (!response) {
|
||||
let restartReload = false;
|
||||
let response: T;
|
||||
try {
|
||||
response = await callback();
|
||||
restartReload ||= restartReloadCallback(response);
|
||||
} catch {
|
||||
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> {
|
||||
const requireClientKeyHalf = await this.stateService.getBiometricRequirePasswordOnStart();
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, storageKey);
|
||||
|
||||
if (requireClientKeyHalf && !clientKeyHalfB64) {
|
||||
throw new Error("Biometric key requirements not met. No client key half provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
|
||||
import { passwords } from "@bitwarden/desktop-native";
|
||||
|
||||
import { BiometricMessage, BiometricStorageAction } from "../types/biometric-message";
|
||||
|
||||
import { BiometricsServiceAbstraction } from "./biometric/index";
|
||||
|
||||
const AuthRequiredSuffix = "_biometric";
|
||||
const AuthenticatedActions = ["getPassword"];
|
||||
|
||||
export class DesktopCredentialStorageListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: BiometricsServiceAbstraction
|
||||
private biometricService: BiometricsServiceAbstraction,
|
||||
private logService: ConsoleLogService
|
||||
) {}
|
||||
|
||||
init() {
|
||||
@@ -22,46 +26,107 @@ export class DesktopCredentialStorageListener {
|
||||
serviceName += message.keySuffix;
|
||||
}
|
||||
|
||||
const authenticationRequired =
|
||||
AuthenticatedActions.includes(message.action) && AuthRequiredSuffix === message.keySuffix;
|
||||
const authenticated = !authenticationRequired || (await this.authenticateBiometric());
|
||||
|
||||
let val: string | boolean = null;
|
||||
if (authenticated && message.action && message.key) {
|
||||
if (message.action && message.key) {
|
||||
if (message.action === "getPassword") {
|
||||
val = await this.getPassword(serviceName, message.key);
|
||||
val = await this.getPassword(serviceName, message.key, message.keySuffix);
|
||||
} else if (message.action === "hasPassword") {
|
||||
const result = await this.getPassword(serviceName, message.key);
|
||||
const result = await passwords.getPassword(serviceName, message.key);
|
||||
val = result != null;
|
||||
} else if (message.action === "setPassword" && message.value) {
|
||||
await passwords.setPassword(serviceName, message.key, message.value);
|
||||
await this.setPassword(serviceName, message.key, message.value, message.keySuffix);
|
||||
} else if (message.action === "deletePassword") {
|
||||
await passwords.deletePassword(serviceName, message.key);
|
||||
await this.deletePassword(serviceName, message.key, message.keySuffix);
|
||||
}
|
||||
}
|
||||
return val;
|
||||
} catch {
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (
|
||||
e.message === "Password not found." ||
|
||||
e.message === "The specified item could not be found in the keychain."
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
|
||||
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 BiometricStorageAction.EnabledForUser:
|
||||
if (!message.key || !message.userId) {
|
||||
break;
|
||||
}
|
||||
val = await this.biometricService.canAuthBiometric({
|
||||
service: serviceName,
|
||||
key: message.key,
|
||||
userId: message.userId,
|
||||
});
|
||||
break;
|
||||
case BiometricStorageAction.OsSupported:
|
||||
val = await this.biometricService.osSupportsBiometric();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return val;
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
|
||||
private async getPassword(serviceName: string, key: string) {
|
||||
let val = await passwords.getPassword(serviceName, key);
|
||||
private async getPassword(serviceName: string, key: string, keySuffix: string) {
|
||||
let val: string;
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
|
||||
} else {
|
||||
val = await passwords.getPassword(serviceName, key);
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(val);
|
||||
} catch (e) {
|
||||
val = await passwords.getPasswordKeytar(serviceName, key);
|
||||
await passwords.setPassword(serviceName, key, val);
|
||||
throw new Error("Password in bad format" + e + val);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
private async authenticateBiometric(): Promise<boolean> {
|
||||
if (this.biometricService) {
|
||||
return await this.biometricService.authenticateBiometric();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private async deletePassword(serviceName: string, key: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
await this.biometricService.deleteBiometricKey(serviceName, key);
|
||||
} else {
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
Account as BaseAccount,
|
||||
AccountSettings as BaseAccountSettings,
|
||||
AccountKeys as BaseAccountKeys,
|
||||
} from "@bitwarden/common/models/domain/account";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class AccountSettings extends BaseAccountSettings {
|
||||
vaultTimeout = -1; // On Restart
|
||||
requirePasswordOnStart?: boolean;
|
||||
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
|
||||
}
|
||||
|
||||
export class AccountKeys extends BaseAccountKeys {
|
||||
biometricEncryptionClientKeyHalf?: Jsonify<EncString>;
|
||||
}
|
||||
|
||||
export class Account extends BaseAccount {
|
||||
settings?: AccountSettings = new AccountSettings();
|
||||
keys?: AccountKeys = new AccountKeys();
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
super(init);
|
||||
|
||||
@@ -331,6 +331,10 @@ form,
|
||||
}
|
||||
}
|
||||
|
||||
.form-group-child {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -2,72 +2,65 @@ import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunc
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/enums";
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { CryptoService } from "@bitwarden/common/services/crypto.service";
|
||||
import { CsprngString } from "@bitwarden/common/types/csprng";
|
||||
|
||||
import { ElectronStateService } from "./electron-state.service.abstraction";
|
||||
|
||||
export class ElectronCryptoService extends CryptoService {
|
||||
constructor(
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilService: PlatformUtilsService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
logService: LogService,
|
||||
stateService: StateService
|
||||
protected override stateService: ElectronStateService
|
||||
) {
|
||||
super(cryptoFunctionService, encryptService, platformUtilService, logService, stateService);
|
||||
super(cryptoFunctionService, encryptService, platformUtilsService, logService, stateService);
|
||||
}
|
||||
|
||||
async hasKeyStored(keySuffix: KeySuffixOptions): Promise<boolean> {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.hasKeyStored(keySuffix);
|
||||
}
|
||||
protected override async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
await super.storeKey(key, userId);
|
||||
|
||||
protected async storeKey(key: SymmetricCryptoKey, userId?: string) {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId });
|
||||
const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId);
|
||||
|
||||
if (storeBiometricKey) {
|
||||
await this.storeBiometricKey(key, userId);
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Auto);
|
||||
}
|
||||
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric, userId)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key.keyB64, { userId: userId });
|
||||
} else {
|
||||
this.clearStoredKey(KeySuffixOptions.Biometric);
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
protected async retrieveKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string) {
|
||||
await this.upgradeSecurelyStoredKey();
|
||||
return super.retrieveKeyFromStorage(keySuffix, userId);
|
||||
protected async storeBiometricKey(key: SymmetricCryptoKey, userId?: string): Promise<void> {
|
||||
let clientEncKeyHalf: CsprngString = null;
|
||||
if (await this.stateService.getBiometricRequirePasswordOnStart({ userId })) {
|
||||
clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userId);
|
||||
}
|
||||
await this.stateService.setCryptoMasterKeyBiometric(
|
||||
{ key: key.keyB64, clientEncKeyHalf },
|
||||
{ userId: userId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 4 Jun 2021 This is temporary upgrade method to move from a single shared stored key to
|
||||
* multiple, unique stored keys for each use, e.g. never logout vs. biometric authentication.
|
||||
*/
|
||||
private async upgradeSecurelyStoredKey() {
|
||||
// attempt key upgrade, but if we fail just delete it. Keys will be stored property upon unlock anyway.
|
||||
const key = await this.stateService.getCryptoMasterKeyB64();
|
||||
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
private async getBiometricEncryptionClientKeyHalf(userId?: string): Promise<CsprngString | null> {
|
||||
try {
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Auto)) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(key);
|
||||
let biometricKey = await this.stateService
|
||||
.getBiometricEncryptionClientKeyHalf({ userId })
|
||||
.then((result) => result?.decrypt(null /* user encrypted */))
|
||||
.then((result) => result as CsprngString);
|
||||
const userKey = await this.getKeyForUserEncryption();
|
||||
if (biometricKey == null && userKey != null) {
|
||||
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
|
||||
await this.stateService.setBiometricEncryptionClientKeyHalf(encKey);
|
||||
}
|
||||
if (await this.shouldStoreKey(KeySuffixOptions.Biometric)) {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(key);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Encountered error while upgrading obsolete Bitwarden secure storage item:`
|
||||
);
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
await this.stateService.setCryptoMasterKeyB64(null);
|
||||
return biometricKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { BiometricMessage, BiometricStorageAction } from "../types/biometric-message";
|
||||
import { isDev, isMacAppStore } from "../utils";
|
||||
|
||||
export class ElectronPlatformUtilsService implements PlatformUtilsService {
|
||||
@@ -169,9 +170,15 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
|
||||
}
|
||||
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await this.stateService.getEnableBiometric();
|
||||
return await ipcRenderer.invoke("biometric", {
|
||||
action: BiometricStorageAction.OsSupported,
|
||||
} as BiometricMessage);
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const val = await ipcRenderer.invoke("biometric", {
|
||||
action: "authenticate",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StateService } from "@bitwarden/common/abstractions/state.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
export abstract class ElectronStateService extends StateService<Account> {
|
||||
getBiometricEncryptionClientKeyHalf: (options?: StorageOptions) => Promise<EncString>;
|
||||
setBiometricEncryptionClientKeyHalf: (
|
||||
value: EncString,
|
||||
options?: StorageOptions
|
||||
) => Promise<void>;
|
||||
getDismissedBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDismissedBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<void>;
|
||||
getBiometricRequirePasswordOnStart: (options?: StorageOptions) => Promise<boolean>;
|
||||
setBiometricRequirePasswordOnStart: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
}
|
||||
80
apps/desktop/src/services/electron-state.service.ts
Normal file
80
apps/desktop/src/services/electron-state.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storage-options";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
import { ElectronStateService as ElectronStateServiceAbstraction } from "./electron-state.service.abstraction";
|
||||
|
||||
export class ElectronStateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements ElectronStateServiceAbstraction
|
||||
{
|
||||
async addAccount(account: Account) {
|
||||
// Apply desktop overides to default account values
|
||||
account = new Account(account);
|
||||
await super.addAccount(account);
|
||||
}
|
||||
|
||||
async getBiometricEncryptionClientKeyHalf(options?: StorageOptions): Promise<EncString> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
const key = account?.keys?.biometricEncryptionClientKeyHalf;
|
||||
return key == null ? null : new EncString(key);
|
||||
}
|
||||
|
||||
async setBiometricEncryptionClientKeyHalf(
|
||||
value: EncString,
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.keys.biometricEncryptionClientKeyHalf = value?.encryptedString;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<boolean> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
return account?.settings?.requirePasswordOnStart;
|
||||
}
|
||||
|
||||
async setBiometricRequirePasswordOnStart(
|
||||
value: boolean,
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.settings.requirePasswordOnStart = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
|
||||
async getDismissedBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<boolean> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
return account?.settings?.dismissedBiometricRequirePasswordOnStartCallout;
|
||||
}
|
||||
|
||||
async setDismissedBiometricRequirePasswordOnStart(options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout = true;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,11 @@ import { GenerateResponse } from "../models/native-messaging/encrypted-message-r
|
||||
import { SuccessStatusResponse } from "../models/native-messaging/encrypted-message-responses/success-status-response";
|
||||
import { UserStatusErrorResponse } from "../models/native-messaging/encrypted-message-responses/user-status-error-response";
|
||||
|
||||
import { StateService } from "./state.service";
|
||||
import { ElectronStateService } from "./electron-state.service";
|
||||
|
||||
export class EncryptedMessageHandlerService {
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private stateService: ElectronStateService,
|
||||
private authService: AuthService,
|
||||
private cipherService: CipherService,
|
||||
private policyService: PolicyService,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/global-state";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
|
||||
export class StateService
|
||||
extends BaseStateService<GlobalState, Account>
|
||||
implements StateServiceAbstraction
|
||||
{
|
||||
async addAccount(account: Account) {
|
||||
// Apply desktop overides to default account values
|
||||
account = new Account(account);
|
||||
await super.addAccount(account);
|
||||
}
|
||||
}
|
||||
11
apps/desktop/src/types/biometric-message.ts
Normal file
11
apps/desktop/src/types/biometric-message.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export enum BiometricStorageAction {
|
||||
EnabledForUser = "enabled",
|
||||
OsSupported = "osSupported",
|
||||
}
|
||||
|
||||
export type BiometricMessage = {
|
||||
action: BiometricStorageAction;
|
||||
keySuffix?: string;
|
||||
key?: string;
|
||||
userId?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user