From 5dd3dda238971872b588b26cce193a670c441a7f Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Nov 2025 20:04:32 +0100 Subject: [PATCH 1/3] android: implement biometric authentication Allows to unlock the android app with the android biometric api (e.g. fingerprint). Can be enabled in the settings. --- contrib/android/buildozer_qml.spec | 4 +- electrum/gui/qml/components/Preferences.qml | 53 +++++ electrum/gui/qml/components/WalletDetails.qml | 14 ++ electrum/gui/qml/components/main.qml | 139 ++++++++++---- .../electrum/biometry/BiometricActivity.java | 168 ++++++++++++++++ .../electrum/biometry/BiometricHelper.java | 20 ++ electrum/gui/qml/qeapp.py | 3 + electrum/gui/qml/qebiometrics.py | 181 ++++++++++++++++++ electrum/gui/qml/qedaemon.py | 7 + electrum/simple_config.py | 5 + 10 files changed, 558 insertions(+), 36 deletions(-) create mode 100644 electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java create mode 100644 electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java create mode 100644 electrum/gui/qml/qebiometrics.py diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index 75328a51e..454618176 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -101,7 +101,7 @@ fullscreen = False # # (list) Permissions -android.permissions = INTERNET, CAMERA, WRITE_EXTERNAL_STORAGE, POST_NOTIFICATIONS +android.permissions = INTERNET, CAMERA, WRITE_EXTERNAL_STORAGE, POST_NOTIFICATIONS, USE_BIOMETRIC # (int) Android API to use (compileSdkVersion) # note: when changing, Dockerfile also needs to be changed to install corresponding build tools @@ -171,7 +171,7 @@ android.gradle_dependencies = com.android.support:support-compat:28.0.0, org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -android.add_activities = org.electrum.qr.SimpleScannerActivity +android.add_activities = org.electrum.qr.SimpleScannerActivity, org.electrum.biometry.BiometricActivity # (list) Put these files or directories in the apk res directory. # The option may be used in three ways, the value may contain one or zero ':' diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 0d34f7036..3626c4908 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -213,6 +213,59 @@ Pane { } } + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + spacing: 0 + // isAvailable checks phone support and if a fingerprint is enrolled on the system + enabled: Biometrics.isAvailable && Daemon.currentWallet + + Connections { + target: Biometrics + function onEnablingFailed(error) { + useBiometrics.checked = false + if (error === 'CANCELLED') { + return // don't show error popup + } + var err = app.messageDialog.createObject(app, { + text: qsTr('Failed to enable biometric authentication: ') + error + }) + err.open() + } + } + + Switch { + id: useBiometrics + checked: Biometrics.isEnabled + onToggled: { + if (activeFocus) { + if (checked) { + if (Daemon.singlePasswordEnabled) { + Biometrics.enable(Daemon.singlePassword) + } else { + useBiometrics.checked = false + var err = app.messageDialog.createObject(app, { + title: qsTr('Unavailable'), + text: [ + qsTr("Cannot activate biometric authentication because you have wallets with different passwords."), + qsTr("To use biometric authentication you first need to change all wallet passwords to the same password.") + ].join("\n") + }) + err.open() + } + } else { + Biometrics.disable() + } + } + } + } + Label { + Layout.fillWidth: true + text: qsTr('Biometric authentication') + wrapMode: Text.Wrap + } + } + RowLayout { Layout.columnSpan: 2 Layout.fillWidth: true diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml index c0fddd99d..68aab9636 100644 --- a/electrum/gui/qml/components/WalletDetails.qml +++ b/electrum/gui/qml/components/WalletDetails.qml @@ -475,6 +475,15 @@ Pane { }) dialog.accepted.connect(function() { var success = Daemon.setPassword(dialog.password) + if (success && Biometrics.isEnabled) { + if (Biometrics.isAvailable) { + // also update the biometric authentication + Biometrics.enable(dialog.password) + } else { + // disable biometric authentication as it is not available + Biometrics.disable() + } + } var done_dialog = app.messageDialog.createObject(app, { title: success ? qsTr('Success') : qsTr('Error'), iconSource: success @@ -546,6 +555,11 @@ Pane { } var error_msg = qsTr('Password change failed') } + if (success && Biometrics.isEnabled) { + // unlikely to happen as this means the user somehow moved from + // a unified password to differing passwords + Biometrics.disable() + } var done_dialog = app.messageDialog.createObject(app, { title: success ? qsTr('Success') : qsTr('Error'), iconSource: success diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index c8d364525..d5aa808fe 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -633,18 +633,63 @@ ApplicationWindow } } - property var _opendialog: undefined + property var _pendingBiometricAuth: null + property var _loadingWalletContext: null + + Connections { + target: Biometrics + function onUnlockSuccess(password) { + if (_pendingBiometricAuth) { + if (_pendingBiometricAuth.action === 'load_wallet') { + _loadingWalletContext = _pendingBiometricAuth + Daemon.loadWallet(_pendingBiometricAuth.path, password) + _pendingBiometricAuth = null + return + } + + var qtobject = _pendingBiometricAuth.qtobject + var method = _pendingBiometricAuth.method + + if (Daemon.currentWallet.verifyPassword(password)) { + qtobject.authProceed() + } else { + console.log("Biometric password invalid falling back to manual input") + handleManualAuth(qtobject, method, _pendingBiometricAuth.authMessage) + } + _pendingBiometricAuth = null + } + } + function onUnlockError(error) { + console.log("Biometric auth failed: " + error) + // we end up here if QEBiometrics fails to give us the decrypted password. The user might + // have cancelled the biometric auth popup or the key got invalidated because a new fingerprint got registered. + if (_pendingBiometricAuth) { + // try manual auth + if (_pendingBiometricAuth.action === 'load_wallet') { + // set loadingWalletContext to disable biometric auth until the OpenWalletDialog is closed + _loadingWalletContext = _pendingBiometricAuth + showOpenWalletDialog(_pendingBiometricAuth.name, _pendingBiometricAuth.path) + } else { + handleManualAuth(_pendingBiometricAuth.qtobject, _pendingBiometricAuth.method, _pendingBiometricAuth.authMessage) + } + _pendingBiometricAuth = null + } + } + } + + property var _opendialog: null property var _opendialog_startup: true function showOpenWalletDialog(name, path) { - if (_opendialog == undefined) { + if (!_opendialog) { _opendialog = openWalletDialog.createObject(app, { name: name, path: path, isStartup: _opendialog_startup, }) _opendialog.closed.connect(function() { - _opendialog = undefined + _opendialog = null + _loadingWalletContext = null // dialog closed, we can allow trying biometric auth again _opendialog_startup = false }) _opendialog.open() @@ -655,7 +700,16 @@ ApplicationWindow target: Daemon function onWalletRequiresPassword(name, path) { console.log('wallet requires password') - showOpenWalletDialog(name, path) + if (Biometrics.isAvailable && Biometrics.isEnabled && !_loadingWalletContext) { + _pendingBiometricAuth = { + action: 'load_wallet', + name: name, + path: path + } + Biometrics.unlock() + } else { + showOpenWalletDialog(name, path) + } } function onWalletOpenError(error) { console.log('wallet open error') @@ -676,6 +730,9 @@ ApplicationWindow var dialog = loadingWalletDialog.createObject(app, { allowClose: false } ) dialog.open() } + function onWalletLoaded() { + _loadingWalletContext = null // either biometric auth or manual auth was successful + } } Connections { @@ -771,43 +828,57 @@ ApplicationWindow } } - if (method == 'wallet') { + if (method === 'wallet') { if (Daemon.currentWallet.verifyPassword('')) { // wallet has no password qtobject.authProceed() - } else { - var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) - dialog.accepted.connect(function() { - if (Daemon.currentWallet.verifyPassword(dialog.password)) { - qtobject.authProceed() - } else { - qtobject.authCancel() - } - }) - dialog.rejected.connect(function() { - qtobject.authCancel() - }) - dialog.open() + return } - } else if (method == 'pin') { - if (Config.pinCode == '') { + } else if (method === 'pin') { + if (Config.pinCode === '') { // no PIN configured handleAuthConfirmationOnly(qtobject, authMessage) - } else { - var dialog = app.pinDialog.createObject(app, { - mode: 'check', - pincode: Config.pinCode, - authMessage: authMessage - }) - dialog.accepted.connect(function() { - qtobject.authProceed() - dialog.close() - }) - dialog.rejected.connect(function() { - qtobject.authCancel() - }) - dialog.open() + return } + } + + if (Biometrics.isAvailable && Biometrics.isEnabled) { + _pendingBiometricAuth = { qtobject: qtobject, method: method, authMessage: authMessage } + Biometrics.unlock() + return + } + + handleManualAuth(qtobject, method, authMessage) + } + + function handleManualAuth(qtobject, method, authMessage) { + if (method == 'wallet') { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (Daemon.currentWallet.verifyPassword(dialog.password)) { + qtobject.authProceed() + } else { + qtobject.authCancel() + } + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() + } else if (method == 'pin') { + var dialog = app.pinDialog.createObject(app, { + mode: 'check', + pincode: Config.pinCode, + authMessage: authMessage + }) + dialog.accepted.connect(function() { + qtobject.authProceed() + dialog.close() + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() } else { console.log('unknown auth method ' + method) qtobject.authCancel() diff --git a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java new file mode 100644 index 000000000..dea680dc6 --- /dev/null +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java @@ -0,0 +1,168 @@ +package org.electrum.biometry; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.content.Intent; +import android.hardware.biometrics.BiometricPrompt; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import android.util.Log; +import android.widget.Toast; + +import java.nio.charset.Charset; +import java.security.KeyStore; +import java.util.concurrent.Executor; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +import org.electrum.electrum.res.R; + +public class BiometricActivity extends Activity { + private static final String TAG = "BiometricActivity"; + private static final String KEY_NAME = "electrum_biometric_key"; + private static final int RESULT_SETUP_FAILED = 101; + private static final int RESULT_POPUP_CANCELLED = 102; + private CancellationSignal cancellationSignal; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.e(TAG, "Biometrics not supported on this Android version (requires API 29+)"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + handleIntent(); + } + + private void handleIntent() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return; + + Intent intent = getIntent(); + String action = intent.getStringExtra("action"); + + Executor executor = getMainExecutor(); + BiometricPrompt biometricPrompt = new BiometricPrompt.Builder(this) + .setTitle("Electrum Wallet") + .setSubtitle("Confirm your identity") + .setNegativeButton("Cancel", executor, (dialog, which) -> { + Log.d(TAG, "Authentication cancelled"); + setResult(RESULT_POPUP_CANCELLED); + finish(); + }) + .build(); + + cancellationSignal = new CancellationSignal(); + + BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + Log.e(TAG, "Authentication error: " + errString); + setResult(RESULT_CANCELED); + finish(); + } + + @Override + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + Log.d(TAG, "Authentication succeeded!"); + handleAuthenticationSuccess(result); + } + + @Override + public void onAuthenticationFailed() { + super.onAuthenticationFailed(); + Log.d(TAG, "Authentication failed"); + } + }; + + try { + if ("ENCRYPT".equals(action)) { + Cipher cipher = getCipher(); + SecretKey secretKey = genSecretKey(); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + biometricPrompt.authenticate(new BiometricPrompt.CryptoObject(cipher), cancellationSignal, executor, callback); + } else if ("DECRYPT".equals(action)) { + String ivStr = intent.getStringExtra("iv"); + byte[] iv = Base64.decode(ivStr, Base64.NO_WRAP); + Cipher cipher = getCipher(); + SecretKey secretKey = getSecretKey(); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); + biometricPrompt.authenticate(new BiometricPrompt.CryptoObject(cipher), cancellationSignal, executor, callback); + } else { + finish(); + } + } catch (Exception e) { + Log.e(TAG, "Setup error", e); + Toast.makeText(this, "Biometric setup failed: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + setResult(RESULT_SETUP_FAILED); + finish(); + } + } + + private void handleAuthenticationSuccess(BiometricPrompt.AuthenticationResult result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return; + try { + BiometricPrompt.CryptoObject cryptoObject = result.getCryptoObject(); + Cipher cipher = cryptoObject.getCipher(); + Intent intent = getIntent(); + String action = intent.getStringExtra("action"); + Intent resultIntent = new Intent(); + + if ("ENCRYPT".equals(action)) { + String data = intent.getStringExtra("data"); // wrap_key string to encrypt + byte[] encrypted = cipher.doFinal(data.getBytes(Charset.forName("UTF-8"))); + resultIntent.putExtra("data", Base64.encodeToString(encrypted, Base64.NO_WRAP)); + resultIntent.putExtra("iv", Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)); + } else { + String dataStr = intent.getStringExtra("data"); // Encrypted blob + byte[] encrypted = Base64.decode(dataStr, Base64.NO_WRAP); + byte[] decrypted = cipher.doFinal(encrypted); + resultIntent.putExtra("data", new String(decrypted, Charset.forName("UTF-8"))); + } + setResult(RESULT_OK, resultIntent); + } catch (Exception e) { + Log.e(TAG, "Crypto error", e); + setResult(RESULT_CANCELED); + } + finish(); + } + + private SecretKey getSecretKey() throws Exception { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + return (SecretKey) keyStore.getKey(KEY_NAME, null); + } + + private SecretKey genSecretKey() throws Exception { + // https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder?hl=en + KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); + KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(KEY_NAME, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true); + + keyGenerator.init(builder.build()); + keyGenerator.generateKey(); + + return getSecretKey(); + } + + private Cipher getCipher() throws Exception { + return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/" + + KeyProperties.BLOCK_MODE_CBC + "/" + + KeyProperties.ENCRYPTION_PADDING_PKCS7); + } +} \ No newline at end of file diff --git a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java new file mode 100644 index 000000000..8be4d3a66 --- /dev/null +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java @@ -0,0 +1,20 @@ +package org.electrum.biometry; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.hardware.biometrics.BiometricManager; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; + +public class BiometricHelper { + public static boolean isAvailable(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // API 30+ + BiometricManager biometricManager = context.getSystemService(BiometricManager.class); + return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS; + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { // API 29 + BiometricManager biometricManager = context.getSystemService(BiometricManager.class); + return biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS; + } + return false; + } +} \ No newline at end of file diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index a4df18b13..640c84c39 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -46,6 +46,7 @@ from .qeswaphelper import QESwapHelper from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard from .qemodelfilter import QEFilterProxyModel from .qebip39recovery import QEBip39RecoveryListModel +from .qebiometrics import QEBiometrics if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -537,6 +538,7 @@ class ElectrumQmlApplication(QGuiApplication): self.daemon = QEDaemon(daemon, self.plugins) self.appController = QEAppController(self, self.plugins) self.maxAmount = QEAmount(is_max=True) + self.biometrics = QEBiometrics(config=config, parent=self) self.context.setContextProperty('AppController', self.appController) self.context.setContextProperty('Config', self.config) self.context.setContextProperty('Network', self.network) @@ -544,6 +546,7 @@ class ElectrumQmlApplication(QGuiApplication): self.context.setContextProperty('FixedFont', self.fixedFont) self.context.setContextProperty('MAX', self.maxAmount) self.context.setContextProperty('QRIP', self.qr_ip_h) + self.context.setContextProperty('Biometrics', self.biometrics) self.context.setContextProperty('BUILD', { 'electrum_version': version.ELECTRUM_VERSION, 'protocol_version': f"[{version.PROTOCOL_VERSION_MIN}, {version.PROTOCOL_VERSION_MAX}]", diff --git a/electrum/gui/qml/qebiometrics.py b/electrum/gui/qml/qebiometrics.py new file mode 100644 index 000000000..bcf46d3e1 --- /dev/null +++ b/electrum/gui/qml/qebiometrics.py @@ -0,0 +1,181 @@ +import os +import secrets +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from electrum.logging import get_logger +from electrum.base_crash_reporter import send_exception_to_crash_reporter +from electrum.crypto import aes_encrypt_with_iv, aes_decrypt_with_iv + +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + + +_logger = get_logger(__name__) + + +jBiometricHelper = None +jBiometricActivity = None +jPythonActivity = None +jIntent = None +jString = None + +if 'ANDROID_DATA' in os.environ: + from jnius import autoclass + from android import activity + jPythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity + jBiometricHelper = autoclass('org.electrum.biometry.BiometricHelper') + jBiometricActivity = autoclass('org.electrum.biometry.BiometricActivity') + jIntent = autoclass('android.content.Intent') + jString = autoclass('java.lang.String') + + +class BiometricAction(str, Enum): + ENCRYPT = "ENCRYPT" + DECRYPT = "DECRYPT" + + +class QEBiometrics(QObject): + REQUEST_CODE_BIOMETRIC_ACTIVITY = 24553 # random 16 bit int + RESULT_CODE_SETUP_FAILED = 101 # codes duplicated from BiometricActivity.java + RESULT_CODE_POPUP_CANCELLED = 102 + + enablingFailed = pyqtSignal(str, arguments=['error']) + unlockSuccess = pyqtSignal(str, arguments=['password']) + unlockError = pyqtSignal(str, arguments=['error']) + + def __init__(self, *, config: 'SimpleConfig', parent=None): + super().__init__(parent) + self.config = config + self._current_action: Optional[BiometricAction] = None + + @pyqtProperty(bool, constant=True) + def isAvailable(self) -> bool: + if 'ANDROID_DATA' not in os.environ: + return False + try: + return jBiometricHelper.isAvailable(jPythonActivity) + except Exception as e: + send_exception_to_crash_reporter(e) + return False + + isEnabledChanged = pyqtSignal() + @pyqtProperty(bool, notify=isEnabledChanged) + def isEnabled(self) -> bool: + return self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION + + @pyqtSlot(str) + def enable(self, unified_wallet_password: str): + """ + We encrypt (`wrap`) the wallet password with a random key 'wrap_key' and encrypt the random key + with the AndroidKeyStore. + Both the encrypted wrap_key and the encrypted wallet password are stored in the config. + The encryption key for the wrap_key is stored in the AndroidKeyStore. + This way the wallet password doesn't have to leave the process. + """ + wrap_key, iv = secrets.token_bytes(32), secrets.token_bytes(16) + wrapped_wallet_password = aes_encrypt_with_iv( + key=wrap_key, + iv=iv, + data=unified_wallet_password.encode('utf-8'), + ) + encrypted_password_bundle = f"{iv.hex()}:{wrapped_wallet_password.hex()}" + self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = encrypted_password_bundle + self._start_activity(BiometricAction.ENCRYPT, data=wrap_key.hex()) + + @pyqtSlot() + def disable(self): + self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False + self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = '' + self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = '' + self.isEnabledChanged.emit() + _logger.info("Android biometric authentication disabled") + + @pyqtSlot() + def unlock(self): + """ + Called when the user needs to authenticate. + Makes the AndroidKeyStore decrypt our encrypted wrap key, we then use the decrypted wrap key + to decrypt the encrypted wallet password. + """ + encrypted_wrap_key = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY + assert encrypted_wrap_key, "shouldn't unlock if biometric auth is disabled" + self._start_activity(BiometricAction.DECRYPT, data=encrypted_wrap_key) + + def _start_activity(self, action: BiometricAction, data: str): + self._current_action = action + + _logger.debug(f"_start_activity: {action.value}, {len(data)=}") + intent = jIntent(jPythonActivity, jBiometricActivity) + intent.putExtra(jString("action"), jString(action.value)) + if action == BiometricAction.ENCRYPT: + intent.putExtra(jString("data"), jString(data)) # wrap_key + elif action == BiometricAction.DECRYPT: + assert ':' in data, f"malformed encrypted_bundle: {data=}" + iv, encrypted_wrap_key = data.split(':') + intent.putExtra(jString("iv"), jString(iv)) + intent.putExtra(jString("data"), jString(encrypted_wrap_key)) + else: + raise ValueError(f"unsupported {action=}") + + activity.bind(on_activity_result=self._on_activity_result) + jPythonActivity.startActivityForResult(intent, self.REQUEST_CODE_BIOMETRIC_ACTIVITY) + + def _on_activity_result(self, requestCode: int, resultCode: int, intent): + if requestCode != self.REQUEST_CODE_BIOMETRIC_ACTIVITY: + return + + action = self._current_action + self._current_action = None + + try: + activity.unbind(on_activity_result=self._on_activity_result) + if resultCode == -1: # RESULT_OK + data = intent.getStringExtra(jString("data")) + if action == BiometricAction.ENCRYPT: + iv = intent.getStringExtra(jString("iv")) + encrypted_bundle = f"{iv}:{data}" + self._on_wrap_key_encrypted(encrypted_bundle=encrypted_bundle) + else: + self._on_wrap_key_decrypted(wrap_key=data) + return + except Exception as e: # prevent exc from getting lost + send_exception_to_crash_reporter(e) + + # on qml side we act on specific errors, so these error strings shouldn't be changed + if resultCode == self.RESULT_CODE_SETUP_FAILED and action == BiometricAction.DECRYPT: + # setup failed, we need to delete the biometry data, it cannot be decrypted anymore + _logger.debug(f"biometric decryption failed, probably invalidated key") + error = 'INVALIDATED' + self.disable() # reset + elif resultCode == self.RESULT_CODE_POPUP_CANCELLED: # user clicked cancel on auth popup + _logger.debug(f"biometric auth cancelled by user") + error = 'CANCELLED' + else: # some other error + _logger.error(f"biometric auth failed: {action=}, {resultCode=}") + error = f"{resultCode=}" + + if action == BiometricAction.DECRYPT: + self.unlockError.emit(error) + else: + self.disable() # reset + self.enablingFailed.emit(error) + + def _on_wrap_key_decrypted(self, *, wrap_key: str): + encrypted_password_bundle = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD + assert encrypted_password_bundle and ':' in encrypted_password_bundle + iv, encrypted_password = encrypted_password_bundle.split(':') + decrypted_password = aes_decrypt_with_iv( + key=bytes.fromhex(wrap_key), + iv=bytes.fromhex(iv), + data=bytes.fromhex(encrypted_password), + ) + self.unlockSuccess.emit(decrypted_password.decode('utf-8')) + + def _on_wrap_key_encrypted(self, *, encrypted_bundle: str): + self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = encrypted_bundle + self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = True + self.isEnabledChanged.emit() + diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index f76a8fbe3..5b2ef6334 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -232,6 +232,13 @@ class QEDaemon(AuthMixin, QObject): if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD: self._use_single_password = self.daemon.update_password_for_directory(old_password=local_password, new_password=local_password) + if not self._use_single_password and self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION: + # we need to disable biometric auth if the user creates wallets with different passwords as + # we only store one encrypted password which is not associated to a specific wallet + self._logger.warning(f"disabling biometric authentication, not in single password mode") + self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False + self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = '' + self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = '' self._password = local_password self.singlePasswordChanged.emit() self._logger.info(f'use single password: {self._use_single_password}') diff --git a/electrum/simple_config.py b/electrum/simple_config.py index a74818626..2623d77f2 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -678,6 +678,11 @@ class SimpleConfig(Logger): WALLET_SHOULD_USE_SINGLE_PASSWORD = ConfigVar('should_use_single_password', default=False, type_=bool) # TODO: consider removing WALLET_DID_USE_SINGLE_PASSWORD once encrypted wallet file headers are available WALLET_DID_USE_SINGLE_PASSWORD = ConfigVar('did_use_single_password', default=False, type_=bool) + WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = ConfigVar('android_use_biometrics', default=False, type_=bool) + # this is the wrap key encrypted with a secret stored in AndroidKeyStore + WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ConfigVar('android_biometrics_encrypted_wrap_key', default='', type_=str) + # this is the "unified wallet password", encrypted with the wrap key + WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ConfigVar('android_biometrics_wrapped_wallet_password', default='', type_=str) # note: 'use_change' and 'multiple_change' are per-wallet settings WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar( 'send_change_to_lightning', default=False, type_=bool, From 6450187902f0802626894f5728d2980cb1bdc21b Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 3 Dec 2025 12:49:27 +0100 Subject: [PATCH 2/3] qeqrscanner: check requestCode on activity result --- electrum/gui/qml/qeqrscanner.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qeqrscanner.py b/electrum/gui/qml/qeqrscanner.py index 297479ba8..f7ed88378 100644 --- a/electrum/gui/qml/qeqrscanner.py +++ b/electrum/gui/qml/qeqrscanner.py @@ -19,6 +19,8 @@ if 'ANDROID_DATA' in os.environ: class QEQRScanner(QObject): + REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int + _logger = get_logger(__name__) foundText = pyqtSignal(str) @@ -54,9 +56,12 @@ class QEQRScanner(QObject): intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint)) activity.bind(on_activity_result=self.on_qr_activity_result) - jpythonActivity.startActivityForResult(intent, 0) + jpythonActivity.startActivityForResult(intent, self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY) def on_qr_activity_result(self, requestCode, resultCode, intent): + if requestCode != self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY: + self._logger.warning(f"got activity result with invalid {requestCode=}") + return try: if resultCode == -1: # RESULT_OK: if (contents := intent.getStringExtra(jString("text"))) is not None: From 47efb8b108e0a0bd6c161b527d0af185bad12826 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 15 Jan 2026 11:30:09 +0100 Subject: [PATCH 3/3] qml: remove pin code authentication Completely removes the pin code authentication from qml. The config option in the wallet preferences has been renamed to "Payment authentication" and now either asks for the Android system authentication (Biometric or system pin/password) if enabled or will ask for the wallet password as fallback. --- electrum/gui/qml/auth.py | 13 +- electrum/gui/qml/components/Pin.qml | 114 ----------------- electrum/gui/qml/components/Preferences.qml | 94 +++++--------- electrum/gui/qml/components/WalletDetails.qml | 19 +++ electrum/gui/qml/components/main.qml | 116 ++++++++---------- .../electrum/biometry/BiometricActivity.java | 31 +++-- .../electrum/biometry/BiometricHelper.java | 5 +- electrum/gui/qml/qebiometrics.py | 32 ++++- electrum/gui/qml/qeconfig.py | 31 ++--- electrum/gui/qml/qedaemon.py | 2 +- electrum/simple_config.py | 2 +- 11 files changed, 176 insertions(+), 283 deletions(-) delete mode 100644 electrum/gui/qml/components/Pin.qml diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py index 3a7500815..05209e2c9 100644 --- a/electrum/gui/qml/auth.py +++ b/electrum/gui/qml/auth.py @@ -5,7 +5,18 @@ from PyQt6.QtCore import pyqtSignal, pyqtSlot from electrum.logging import get_logger -def auth_protect(func=None, reject=None, method='pin', message=''): +def auth_protect(func=None, reject=None, method='payment_auth', message=''): + """ + Supported methods: + * payment_auth: If the user has enabled the 'Payment authentication' config + they need to authenticate to continue. If biometrics are enabled they + can authenticate using the Android system dialog, else they will see the + wallet password dialog. + If the option is disabled they will have to confirm a dialog. + * wallet: Same as payment_auth, but not dependent on user configuration, + always requires authentication. + * wallet_password_only: No biometric/system authentication, user has to enter wallet password. + """ if func is None: return partial(auth_protect, reject=reject, method=method, message=message) diff --git a/electrum/gui/qml/components/Pin.qml b/electrum/gui/qml/components/Pin.qml deleted file mode 100644 index 1f222c0a8..000000000 --- a/electrum/gui/qml/components/Pin.qml +++ /dev/null @@ -1,114 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import QtQuick.Controls.Material - -import org.electrum 1.0 - -import "controls" - -ElDialog { - id: root - - property bool canCancel: true - property string mode // [check, enter, change] - property string pincode // old one passed in when change, new one passed out - property bool checkError: false - property string authMessage - property int _phase: mode == 'enter' ? 1 : 0 // 0 = existing pin, 1 = new pin, 2 = re-enter new pin - property string _pin - - title: authMessage ? authMessage : qsTr('PIN') - iconSource: Qt.resolvedUrl('../../icons/lock.png') - width: parent.width * 3/4 - z: 1000 - focus: true - closePolicy: canCancel ? Popup.CloseOnEscape | Popup.CloseOnPressOutside : Popup.NoAutoClose - allowClose: canCancel - needsSystemBarPadding: false - - anchors.centerIn: parent - - Overlay.modal: Rectangle { - color: canCancel ? "#aa000000" : "#ff000000" - } - - function submit() { - if (_phase == 0) { - if (pin.text == pincode) { - pin.text = '' - if (mode == 'check') - accepted() - else - _phase = 1 - } else { - pin.text = '' - checkError = true - } - } else if (_phase == 1) { - _pin = pin.text - pin.text = '' - _phase = 2 - } else if (_phase == 2) { - if (_pin == pin.text) { - pincode = pin.text - accepted() - } else { - pin.text = '' - checkError = true - } - } - } - - onAccepted: result = Dialog.Accepted - onRejected: result = Dialog.Rejected - onClosed: { - if (!root.result) { - root.reject() // make sure we reject the authed fn() - } - } - - ColumnLayout { - width: parent.width - - Label { - text: [qsTr('Enter PIN'), qsTr('Enter New PIN'), qsTr('Re-enter New PIN')][_phase] - font.pixelSize: constants.fontSizeXLarge - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.Wrap - Layout.fillWidth: true - } - - TextField { - id: pin - Layout.preferredWidth: fontMetrics.advanceWidth(passwordCharacter) * 6 + pin.leftPadding + pin.rightPadding - Layout.preferredHeight: fontMetrics.height + pin.topPadding + pin.bottomPadding - Layout.alignment: Qt.AlignHCenter - font.pixelSize: constants.fontSizeXXLarge - maximumLength: 6 - inputMethodHints: Qt.ImhDigitsOnly - - echoMode: TextInput.Password - focus: true - onTextChanged: { - checkError = false - if (text.length == 6) { - submit() - } - } - } - - Label { - opacity: checkError ? 1 : 0 - text: _phase == 0 ? qsTr('Wrong PIN') : qsTr('PIN doesn\'t match') - color: constants.colorError - Layout.alignment: Qt.AlignHCenter - } - } - - FontMetrics { - id: fontMetrics - font: pin.font - } - -} diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 3626c4908..fe560d37f 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -157,62 +157,6 @@ Pane { } } - RowLayout { - Layout.fillWidth: true - spacing: 0 - Switch { - id: usePin - checked: Config.pinCode - onCheckedChanged: { - if (activeFocus) { - console.log('PIN active ' + checked) - if (checked) { - var dialog = pinSetup.createObject(preferences, {mode: 'enter'}) - dialog.accepted.connect(function() { - Config.pinCode = dialog.pincode - dialog.close() - }) - dialog.rejected.connect(function() { - checked = false - }) - dialog.open() - } else { - focus = false - Config.pinCode = '' - // re-add binding, pincode still set if auth failed - checked = Qt.binding(function () { return Config.pinCode }) - } - } - - } - } - Label { - Layout.fillWidth: true - text: qsTr('PIN protect payments') - wrapMode: Text.Wrap - } - } - - Pane { - background: Rectangle { color: Material.dialogColor } - padding: 0 - visible: Config.pinCode != '' - FlatButton { - text: qsTr('Modify') - onClicked: { - var dialog = pinSetup.createObject(preferences, { - mode: 'change', - pincode: Config.pinCode - }) - dialog.accepted.connect(function() { - Config.pinCode = dialog.pincode - dialog.close() - }) - dialog.open() - } - } - } - RowLayout { Layout.columnSpan: 2 Layout.fillWidth: true @@ -223,7 +167,6 @@ Pane { Connections { target: Biometrics function onEnablingFailed(error) { - useBiometrics.checked = false if (error === 'CANCELLED') { return // don't show error popup } @@ -237,8 +180,9 @@ Pane { Switch { id: useBiometrics checked: Biometrics.isEnabled - onToggled: { + onCheckedChanged: { if (activeFocus) { + useBiometrics.focus = false if (checked) { if (Daemon.singlePasswordEnabled) { Biometrics.enable(Daemon.singlePassword) @@ -254,7 +198,7 @@ Pane { err.open() } } else { - Biometrics.disable() + Biometrics.disableProtected() } } } @@ -266,6 +210,33 @@ Pane { } } + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + spacing: 0 + + property bool noWalletPassword: Daemon.currentWallet ? Daemon.currentWallet.verifyPassword('') : true + enabled: Daemon.currentWallet && !noWalletPassword + + Switch { + id: paymentAuthentication + // showing the toggle as checked even if the wallet has no password would be misleading + checked: Config.paymentAuthentication && !(Daemon.currentWallet && parent.noWalletPassword) + onCheckedChanged: { + if (activeFocus) { + // will request authentication when checked = false + console.log('paymentAuthentication: ' + checked) + Config.paymentAuthentication = checked; + } + } + } + Label { + Layout.fillWidth: true + text: qsTr('Payment authentication') + wrapMode: Text.Wrap + } + } + RowLayout { Layout.columnSpan: 2 Layout.fillWidth: true @@ -515,11 +486,6 @@ Pane { } } - Component { - id: pinSetup - Pin {} - } - Component.onCompleted: { language.currentIndex = language.indexOfValue(Config.language) baseUnit.currentIndex = _baseunits.indexOf(Config.baseUnit) diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml index 68aab9636..a86cce3dd 100644 --- a/electrum/gui/qml/components/WalletDetails.qml +++ b/electrum/gui/qml/components/WalletDetails.qml @@ -577,6 +577,25 @@ Pane { } } + Connections { + target: Biometrics + function onEnablingFailed(error) { + if (error === 'CANCELLED') { + var biometrics_disabled_dialog = app.messageDialog.createObject(app, { + title: qsTr('Biometric Authentication'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: qsTr('Biometric authentication disabled. You can enable it again in the settings.') + }) + biometrics_disabled_dialog.open() + return + } + var err = app.messageDialog.createObject(app, { + text: qsTr('Failed to update biometric authentication to new password: ') + error + }) + err.open() + } + } + Component { id: importAddressesKeysDialog ImportAddressesKeysDialog { diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index d5aa808fe..5a5fb5875 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -419,14 +419,6 @@ ApplicationWindow } } - property alias pinDialog: _pinDialog - Component { - id: _pinDialog - Pin { - onClosed: destroy() - } - } - property alias genericShareDialog: _genericShareDialog Component { id: _genericShareDialog @@ -639,42 +631,49 @@ ApplicationWindow Connections { target: Biometrics function onUnlockSuccess(password) { - if (_pendingBiometricAuth) { - if (_pendingBiometricAuth.action === 'load_wallet') { - _loadingWalletContext = _pendingBiometricAuth - Daemon.loadWallet(_pendingBiometricAuth.path, password) - _pendingBiometricAuth = null + if (app._pendingBiometricAuth) { + if (app._pendingBiometricAuth.action === 'load_wallet') { + app._loadingWalletContext = _pendingBiometricAuth + Daemon.loadWallet(app._pendingBiometricAuth.path, password) + app._pendingBiometricAuth = null return } - var qtobject = _pendingBiometricAuth.qtobject - var method = _pendingBiometricAuth.method + let qtobject = app._pendingBiometricAuth.qtobject + let method = app._pendingBiometricAuth.method if (Daemon.currentWallet.verifyPassword(password)) { qtobject.authProceed() } else { - console.log("Biometric password invalid falling back to manual input") - handleManualAuth(qtobject, method, _pendingBiometricAuth.authMessage) + console.warn("Biometric password invalid falling back to manual input") + // this shouldn't really happen so we better disable biometric auth + Biometrics.disable() + handleManualAuth(qtobject, method, app._pendingBiometricAuth.authMessage) } - _pendingBiometricAuth = null + app._pendingBiometricAuth = null } } + function onUnlockError(error) { console.log("Biometric auth failed: " + error) // we end up here if QEBiometrics fails to give us the decrypted password. The user might // have cancelled the biometric auth popup or the key got invalidated because a new fingerprint got registered. - if (_pendingBiometricAuth) { - // try manual auth - if (_pendingBiometricAuth.action === 'load_wallet') { + if (app._pendingBiometricAuth) { + if (app._pendingBiometricAuth.action === 'load_wallet') { // set loadingWalletContext to disable biometric auth until the OpenWalletDialog is closed - _loadingWalletContext = _pendingBiometricAuth - showOpenWalletDialog(_pendingBiometricAuth.name, _pendingBiometricAuth.path) + app._loadingWalletContext = app._pendingBiometricAuth + showOpenWalletDialog(app._pendingBiometricAuth.name, app._pendingBiometricAuth.path) } else { - handleManualAuth(_pendingBiometricAuth.qtobject, _pendingBiometricAuth.method, _pendingBiometricAuth.authMessage) + console.log('biometric auth failed, not falling back to passwordDialog') + app._pendingBiometricAuth.qtobject.authCancel() // no fallback to password dialog } - _pendingBiometricAuth = null + app._pendingBiometricAuth = null } } + + function onAuthRequired(method, authMessage) { + handleAuthRequired(Biometrics, method, authMessage) + } } property var _opendialog: null @@ -689,7 +688,7 @@ ApplicationWindow }) _opendialog.closed.connect(function() { _opendialog = null - _loadingWalletContext = null // dialog closed, we can allow trying biometric auth again + app._loadingWalletContext = null // dialog closed, we can allow trying biometric auth again _opendialog_startup = false }) _opendialog.open() @@ -700,8 +699,8 @@ ApplicationWindow target: Daemon function onWalletRequiresPassword(name, path) { console.log('wallet requires password') - if (Biometrics.isAvailable && Biometrics.isEnabled && !_loadingWalletContext) { - _pendingBiometricAuth = { + if (Biometrics.isAvailable && Biometrics.isEnabled && !app._loadingWalletContext) { + app._pendingBiometricAuth = { action: 'load_wallet', name: name, path: path @@ -731,7 +730,7 @@ ApplicationWindow dialog.open() } function onWalletLoaded() { - _loadingWalletContext = null // either biometric auth or manual auth was successful + app._loadingWalletContext = null // either biometric auth or manual auth was successful } } @@ -818,42 +817,41 @@ ApplicationWindow function handleAuthRequired(qtobject, method, authMessage) { console.log('auth using method ' + method) - if (method == 'wallet_else_pin') { - // if there is a loaded wallet and all wallets use the same password, use that - // else delegate to pin auth - if (Daemon.currentWallet && Daemon.singlePasswordEnabled) { + if (method === 'payment_auth') { + if (Config.paymentAuthentication) { + // treat like a wallet auth request method = 'wallet' } else { - method = 'pin' - } - } - - if (method === 'wallet') { - if (Daemon.currentWallet.verifyPassword('')) { - // wallet has no password - qtobject.authProceed() - return - } - } else if (method === 'pin') { - if (Config.pinCode === '') { - // no PIN configured handleAuthConfirmationOnly(qtobject, authMessage) return } } - if (Biometrics.isAvailable && Biometrics.isEnabled) { - _pendingBiometricAuth = { qtobject: qtobject, method: method, authMessage: authMessage } - Biometrics.unlock() - return + if (Daemon.currentWallet.verifyPassword('')) { + // wallet has no password + qtobject.authProceed() + return + } + + if (method !== 'wallet_password_only') { + if (Biometrics.isAvailable && Biometrics.isEnabled) { + app._pendingBiometricAuth = { + qtobject: qtobject, + method: method, + authMessage: authMessage + } + Biometrics.unlock(authMessage) + return + } } handleManualAuth(qtobject, method, authMessage) } function handleManualAuth(qtobject, method, authMessage) { - if (method == 'wallet') { - var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + // 'payment_auth' should have been converted to 'wallet' at this point + if (method === 'wallet' || method === 'wallet_password_only') { + var dialog = app.passwordDialog.createObject(app, authMessage ? {'title': authMessage} : {}) dialog.accepted.connect(function() { if (Daemon.currentWallet.verifyPassword(dialog.password)) { qtobject.authProceed() @@ -865,20 +863,6 @@ ApplicationWindow qtobject.authCancel() }) dialog.open() - } else if (method == 'pin') { - var dialog = app.pinDialog.createObject(app, { - mode: 'check', - pincode: Config.pinCode, - authMessage: authMessage - }) - dialog.accepted.connect(function() { - qtobject.authProceed() - dialog.close() - }) - dialog.rejected.connect(function() { - qtobject.authCancel() - }) - dialog.open() } else { console.log('unknown auth method ' + method) qtobject.authCancel() diff --git a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java index dea680dc6..acf94e8fc 100644 --- a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java @@ -5,6 +5,7 @@ import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.content.Intent; +import android.hardware.biometrics.BiometricManager; import android.hardware.biometrics.BiometricPrompt; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; @@ -34,8 +35,8 @@ public class BiometricActivity extends Activity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Log.e(TAG, "Biometrics not supported on this Android version (requires API 29+)"); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Log.e(TAG, "Biometrics not supported on this Android version (requires API 30+)"); setResult(RESULT_CANCELED); finish(); return; @@ -45,20 +46,17 @@ public class BiometricActivity extends Activity { } private void handleIntent() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; Intent intent = getIntent(); String action = intent.getStringExtra("action"); + String authMessage = intent.getStringExtra("auth_message"); Executor executor = getMainExecutor(); BiometricPrompt biometricPrompt = new BiometricPrompt.Builder(this) .setTitle("Electrum Wallet") - .setSubtitle("Confirm your identity") - .setNegativeButton("Cancel", executor, (dialog, which) -> { - Log.d(TAG, "Authentication cancelled"); - setResult(RESULT_POPUP_CANCELLED); - finish(); - }) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .setSubtitle(authMessage) .build(); cancellationSignal = new CancellationSignal(); @@ -67,8 +65,17 @@ public class BiometricActivity extends Activity { @Override public void onAuthenticationError(int errorCode, CharSequence errString) { super.onAuthenticationError(errorCode, errString); - Log.e(TAG, "Authentication error: " + errString); - setResult(RESULT_CANCELED); + Log.e(TAG, "Authentication error: " + errorCode + " " + errString); + + if ( + errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED || + errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED || + errorCode == BiometricPrompt.BIOMETRIC_ERROR_TIMEOUT + ) { + setResult(RESULT_POPUP_CANCELLED); + } else { + setResult(RESULT_CANCELED); + } finish(); } @@ -152,7 +159,7 @@ public class BiometricActivity extends Activity { .setBlockModes(KeyProperties.BLOCK_MODE_CBC) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) .setUserAuthenticationRequired(true) - .setInvalidatedByBiometricEnrollment(true); + .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG | KeyProperties.AUTH_DEVICE_CREDENTIAL); keyGenerator.init(builder.build()); keyGenerator.generateKey(); diff --git a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java index 8be4d3a66..fd0922711 100644 --- a/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java @@ -10,10 +10,7 @@ public class BiometricHelper { public static boolean isAvailable(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // API 30+ BiometricManager biometricManager = context.getSystemService(BiometricManager.class); - return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS; - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { // API 29 - BiometricManager biometricManager = context.getSystemService(BiometricManager.class); - return biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS; + return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS; } return false; } diff --git a/electrum/gui/qml/qebiometrics.py b/electrum/gui/qml/qebiometrics.py index bcf46d3e1..3d806a68a 100644 --- a/electrum/gui/qml/qebiometrics.py +++ b/electrum/gui/qml/qebiometrics.py @@ -3,11 +3,14 @@ import secrets from enum import Enum from typing import Optional, TYPE_CHECKING +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty + +from electrum.i18n import _ from electrum.logging import get_logger from electrum.base_crash_reporter import send_exception_to_crash_reporter from electrum.crypto import aes_encrypt_with_iv, aes_decrypt_with_iv -from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from .auth import auth_protect, AuthMixin if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -37,7 +40,7 @@ class BiometricAction(str, Enum): DECRYPT = "DECRYPT" -class QEBiometrics(QObject): +class QEBiometrics(AuthMixin, QObject): REQUEST_CODE_BIOMETRIC_ACTIVITY = 24553 # random 16 bit int RESULT_CODE_SETUP_FAILED = 101 # codes duplicated from BiometricActivity.java RESULT_CODE_POPUP_CANCELLED = 102 @@ -94,22 +97,40 @@ class QEBiometrics(QObject): _logger.info("Android biometric authentication disabled") @pyqtSlot() - def unlock(self): + @auth_protect(method='wallet_password_only', reject='_disable_protected_failed') + def disableProtected(self): + """ + Exists to ensure the user knows the wallet password when manually disabling + biometric authentication. If they don't remember the password they can still do a seed + backup or transactions if biometrics stay enabled. However, note it is still possible for + biometrics to get disabled automatically on invalidation or error, so this cannot + fully protect the user from forgetting their wallet password either. + """ + self.disable() + + def _disable_protected_failed(self): + self.isEnabledChanged.emit() + + @pyqtSlot() + @pyqtSlot(str) + def unlock(self, auth_message: str = None): """ Called when the user needs to authenticate. Makes the AndroidKeyStore decrypt our encrypted wrap key, we then use the decrypted wrap key to decrypt the encrypted wallet password. + auth_message is shown in the system auth popup and defaults to 'Confirm your identity'. """ encrypted_wrap_key = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY assert encrypted_wrap_key, "shouldn't unlock if biometric auth is disabled" - self._start_activity(BiometricAction.DECRYPT, data=encrypted_wrap_key) + self._start_activity(BiometricAction.DECRYPT, data=encrypted_wrap_key, auth_message=auth_message) - def _start_activity(self, action: BiometricAction, data: str): + def _start_activity(self, action: BiometricAction, data: str, auth_message: str = None): self._current_action = action _logger.debug(f"_start_activity: {action.value}, {len(data)=}") intent = jIntent(jPythonActivity, jBiometricActivity) intent.putExtra(jString("action"), jString(action.value)) + intent.putExtra(jString("auth_message"), jString(auth_message or _("Confirm your identity"))) if action == BiometricAction.ENCRYPT: intent.putExtra(jString("data"), jString(data)) # wrap_key elif action == BiometricAction.DECRYPT: @@ -178,4 +199,3 @@ class QEBiometrics(QObject): self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = encrypted_bundle self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = True self.isEnabledChanged.emit() - diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d0d4d79e1..8c0607b51 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -153,23 +153,26 @@ class QEConfig(AuthMixin, QObject): self.config.WALLET_PAYREQ_EXPIRY_SECONDS = expiry self.requestExpiryChanged.emit() - pinCodeChanged = pyqtSignal() - @pyqtProperty(str, notify=pinCodeChanged) - def pinCode(self): - return self.config.CONFIG_PIN_CODE or "" + paymentAuthenticationChanged = pyqtSignal() + @pyqtProperty(bool, notify=paymentAuthenticationChanged) + def paymentAuthentication(self): + return self.config.GUI_QML_PAYMENT_AUTHENTICATION - @pinCode.setter - def pinCode(self, pin_code): - if pin_code == '': - self.pinCodeRemoveAuth() + @paymentAuthentication.setter + def paymentAuthentication(self, enabled: bool): + if enabled: + self.config.GUI_QML_PAYMENT_AUTHENTICATION = True + self.paymentAuthenticationChanged.emit() else: - self.config.CONFIG_PIN_CODE = pin_code - self.pinCodeChanged.emit() + self._disable_payment_authentication() - @auth_protect(method='wallet_else_pin') - def pinCodeRemoveAuth(self): - self.config.CONFIG_PIN_CODE = "" - self.pinCodeChanged.emit() + @auth_protect(method='wallet', reject='_payment_auth_reject') + def _disable_payment_authentication(self): + self.config.GUI_QML_PAYMENT_AUTHENTICATION = False + self.paymentAuthenticationChanged.emit() + + def _payment_auth_reject(self): + self.paymentAuthenticationChanged.emit() useGossipChanged = pyqtSignal() @pyqtProperty(bool, notify=useGossipChanged) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 5b2ef6334..311eebc18 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -388,7 +388,7 @@ class QEDaemon(AuthMixin, QObject): return f'wallet_{i}' @pyqtSlot() - @auth_protect(method='wallet') + @auth_protect(method='wallet_password_only') def startChangePassword(self): if self._use_single_password: self.requestNewPassword.emit() diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 2623d77f2..85ac6d2d3 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -853,6 +853,7 @@ Warning: setting this to too low will result in lots of payment failures."""), GUI_QML_ADDRESS_LIST_SHOW_USED = ConfigVar('address_list_show_used', default=False, type_=bool) GUI_QML_ALWAYS_ALLOW_SCREENSHOTS = ConfigVar('android_always_allow_screenshots', default=False, type_=bool) GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY = ConfigVar('android_set_max_brightness_on_qr_display', default=True, type_=bool) + GUI_QML_PAYMENT_AUTHENTICATION = ConfigVar('qml_payment_authentication', default=False, type_=bool) BTC_AMOUNTS_DECIMAL_POINT = ConfigVar('decimal_point', default=DECIMAL_POINT_DEFAULT, type_=int) BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT = ConfigVar( @@ -924,7 +925,6 @@ Warning: setting this to too low will result in lots of payment failures."""), RECENTLY_OPEN_WALLET_FILES = ConfigVar('recently_open', default=None) IO_DIRECTORY = ConfigVar('io_dir', default=os.path.expanduser('~'), type_=str) WALLET_BACKUP_DIRECTORY = ConfigVar('backup_dir', default=None, type_=str) - CONFIG_PIN_CODE = ConfigVar('pin_code', default=None, type_=str) QR_READER_FLIP_X = ConfigVar('qrreader_flip_x', default=True, type_=bool) WIZARD_DONT_CREATE_SEGWIT = ConfigVar('nosegwit', default=False, type_=bool) CONFIG_FORGET_CHANGES = ConfigVar('forget_config', default=False, type_=bool)