From 5dd3dda238971872b588b26cce193a670c441a7f Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Nov 2025 20:04:32 +0100 Subject: [PATCH] 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,