What changed, and why it matters
This commit removes the old 6-digit PIN authentication from Electrum's Android/QML interface and replaces it with a single 'Payment authentication' setting. When enabled, the app now uses the phone's built-in biometric or system PIN/password prompt; when disabled, it just asks the user to confirm. The change also tightens Android biometric security by requiring Android 11+ and allowing only strong biometrics or device credentials. It is a feature redesign rather than a clear-cut vulnerability fix, but it likely addresses weaknesses in the previous custom PIN implementation.
Treat as a security-hardening redesign worth reviewing in context. Verify that the removed PIN code is no longer accepted anywhere, that the new biometric fallback cannot be bypassed, and that the wallet password is required for all sensitive operations (changing password, disabling biometrics, disabling payment auth). No immediate incident response is indicated by the commit alone.
Security signals we found
Removal of custom 6-digit PIN UI and storage (CONFIG_PIN_CODE)
Replacement with Android system biometric/device-credential authentication
Minimum Android SDK raised from API 29 (Q) to API 30 (R)
Biometric key now requires AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL
Disabling biometric auth now requires wallet password
Disabling payment authentication now requires wallet password
Change-password operation restricted to wallet password only
Evidence from the diff
The patch deletes the custom Pin.qml component and CONFIG_PIN_CODE storage, renames the preference to GUI_QML_PAYMENT_AUTHENTICATION, and changes the auth_protect decorator’s default method from ‘pin’ to ‘payment_auth’. The new flow delegates to Android BiometricPrompt with DEVICE_CREDENTIAL fallback, raises the minimum SDK from API 29 to API 30, and uses setUserAuthenticationParameters with AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL. Disabling biometric authentication now requires the wallet password (disableProtected), and disabling payment authentication also requires authentication. Change-password is switched to wallet_password_only so it cannot be satisfied by biometrics alone.
Changed components
electrum/gui/qml/auth.pyelectrum/gui/qml/components/Pin.qmlelectrum/gui/qml/components/Preferences.qmlelectrum/gui/qml/components/WalletDetails.qmlelectrum/gui/qml/components/main.qmlelectrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.javaelectrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.javaelectrum/gui/qml/qebiometrics.pyelectrum/gui/qml/qeconfig.pyelectrum/gui/qml/qedaemon.pyelectrum/simple_config.pyInspect captured patch +177 / −284
diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py
index 3a75008..05209e2 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 1f222c0..0000000
--- 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 3626c49..fe560d3 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 68aab96..a86cce3 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 d5aa808..5a5fb58 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 dea680d..acf94e8 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 8be4d3a..fd09227 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 bcf46d3..3d806a6 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 d0d4d79..8c0607b 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 ""
-
- @pinCode.setter
- def pinCode(self, pin_code):
- if pin_code == '':
- self.pinCodeRemoveAuth()
+ paymentAuthenticationChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=paymentAuthenticationChanged)
+ def paymentAuthentication(self):
+ return self.config.GUI_QML_PAYMENT_AUTHENTICATION
+
+ @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 5b2ef63..311eebc 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 2623d77..85ac6d2 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)
Why this scored 45/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.