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/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 0d34f7036..fe560d37f 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -158,59 +158,83 @@ 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) { + 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: usePin - checked: Config.pinCode + id: useBiometrics + checked: Biometrics.isEnabled onCheckedChanged: { if (activeFocus) { - console.log('PIN active ' + checked) + useBiometrics.focus = false 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() + 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 { - focus = false - Config.pinCode = '' - // re-add binding, pincode still set if auth failed - checked = Qt.binding(function () { return Config.pinCode }) + Biometrics.disableProtected() } } - } } Label { Layout.fillWidth: true - text: qsTr('PIN protect payments') + text: qsTr('Biometric authentication') 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 + 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 { @@ -462,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 c0fddd99d..a86cce3dd 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 @@ -563,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 c8d364525..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 @@ -633,18 +625,70 @@ ApplicationWindow } } - property var _opendialog: undefined + property var _pendingBiometricAuth: null + property var _loadingWalletContext: null + + Connections { + target: Biometrics + function onUnlockSuccess(password) { + if (app._pendingBiometricAuth) { + if (app._pendingBiometricAuth.action === 'load_wallet') { + app._loadingWalletContext = _pendingBiometricAuth + Daemon.loadWallet(app._pendingBiometricAuth.path, password) + app._pendingBiometricAuth = null + return + } + + let qtobject = app._pendingBiometricAuth.qtobject + let method = app._pendingBiometricAuth.method + + if (Daemon.currentWallet.verifyPassword(password)) { + qtobject.authProceed() + } else { + 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) + } + 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 (app._pendingBiometricAuth) { + if (app._pendingBiometricAuth.action === 'load_wallet') { + // set loadingWalletContext to disable biometric auth until the OpenWalletDialog is closed + app._loadingWalletContext = app._pendingBiometricAuth + showOpenWalletDialog(app._pendingBiometricAuth.name, app._pendingBiometricAuth.path) + } else { + console.log('biometric auth failed, not falling back to passwordDialog') + app._pendingBiometricAuth.qtobject.authCancel() // no fallback to password dialog + } + app._pendingBiometricAuth = null + } + } + + function onAuthRequired(method, authMessage) { + handleAuthRequired(Biometrics, method, authMessage) + } + } + + 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 + app._loadingWalletContext = null // dialog closed, we can allow trying biometric auth again _opendialog_startup = false }) _opendialog.open() @@ -655,7 +699,16 @@ ApplicationWindow target: Daemon function onWalletRequiresPassword(name, path) { console.log('wallet requires password') - showOpenWalletDialog(name, path) + if (Biometrics.isAvailable && Biometrics.isEnabled && !app._loadingWalletContext) { + app._pendingBiometricAuth = { + action: 'load_wallet', + name: name, + path: path + } + Biometrics.unlock() + } else { + showOpenWalletDialog(name, path) + } } function onWalletOpenError(error) { console.log('wallet open error') @@ -676,6 +729,9 @@ ApplicationWindow var dialog = loadingWalletDialog.createObject(app, { allowClose: false } ) dialog.open() } + function onWalletLoaded() { + app._loadingWalletContext = null // either biometric auth or manual auth was successful + } } Connections { @@ -761,53 +817,52 @@ 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' + handleAuthConfirmationOnly(qtobject, authMessage) + return } } - 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() - } - } 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, + 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 - }) - dialog.accepted.connect(function() { - qtobject.authProceed() - dialog.close() - }) - dialog.rejected.connect(function() { - qtobject.authCancel() - }) - dialog.open() + } + Biometrics.unlock(authMessage) + return } + } + + handleManualAuth(qtobject, method, authMessage) + } + + function handleManualAuth(qtobject, method, authMessage) { + // '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() + } else { + qtobject.authCancel() + } + }) + 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..acf94e8fc --- /dev/null +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java @@ -0,0 +1,175 @@ +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.BiometricManager; +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.R) { + Log.e(TAG, "Biometrics not supported on this Android version (requires API 30+)"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + handleIntent(); + } + + private void handleIntent() { + 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") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .setSubtitle(authMessage) + .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: " + 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(); + } + + @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) + .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG | KeyProperties.AUTH_DEVICE_CREDENTIAL); + + 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..fd0922711 --- /dev/null +++ b/electrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.java @@ -0,0 +1,17 @@ +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.Authenticators.DEVICE_CREDENTIAL) == 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 db47c89e3..e3971561a 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..3d806a68a --- /dev/null +++ b/electrum/gui/qml/qebiometrics.py @@ -0,0 +1,201 @@ +import os +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 .auth import auth_protect, AuthMixin + +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(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 + + 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() + @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, auth_message=auth_message) + + 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: + 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/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 f76a8fbe3..311eebc18 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}') @@ -381,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/gui/qml/qeqrscanner.py b/electrum/gui/qml/qeqrscanner.py index 7900a20a0..be93bbf1f 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,7 +56,7 @@ 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) @pyqtSlot() def close(self): @@ -62,6 +64,9 @@ class QEQRScanner(QObject): pass 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: diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 2d3ec005e..f1bdf240c 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -679,6 +679,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, @@ -849,6 +854,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( @@ -920,7 +926,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)