What changed, and why it matters
This commit adds optional fingerprint unlock to the Electrum Android app. It stores an encrypted copy of the wallet password on the device, protected by Android's built-in biometric/keystore system. The change is a feature addition, not a fix for a known bug or attack. There are some design choices worth reviewing—such as how the encrypted password is stored and what happens if a user adds a new fingerprint—but nothing in the commit itself shows an active vulnerability being exploited.
Treat this as a feature commit requiring security review rather than an emergency patch. Reviewers should verify: (1) the encrypted password bundle is not exposed to backups or accessible to other apps; (2) the password is cleared from memory promptly after use; (3) key invalidation behavior is clearly communicated to users; (4) the fallback to manual password entry remains secure; and (5) the AES-CBC IVs are unique per enable operation. No immediate user action is indicated by the commit alone.
Security signals we found
New Android permission USE_BIOMETRIC added
New Java activity handling biometric crypto operations
Wallet password encrypted at rest using two-layer AES encryption
AndroidKeyStore key requires user authentication and is invalidated on biometric enrollment changes
Biometric auth is disabled when single-password mode is no longer possible
No explicit security bug or CVE fix present in the diff
Evidence from the diff
The patch introduces Android biometric authentication for the QML GUI. A Java BiometricActivity uses AndroidKeyStore-backed AES/CBC/PKCS7 to encrypt/decrypt a 32-byte wrap key. The wrap key then AES-encrypts the unified wallet password. Both ciphertexts are persisted in SimpleConfig. On unlock, the biometric prompt decrypts the wrap key, which decrypts the wallet password, which is passed back to QML and used to load/authenticate the wallet. The implementation requires API 29+, uses setUserAuthenticationRequired(true) and setInvalidatedByBiometricEnrollment(true), and disables biometric auth when single-password mode is lost or the key is invalidated.
Changed components
Electrum Android QML GUIelectrum/gui/qml/qebiometrics.pyelectrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.javaelectrum/gui/qml/java_classes/org/electrum/biometry/BiometricHelper.javaelectrum/gui/qml/components/main.qmlelectrum/gui/qml/components/Preferences.qmlelectrum/gui/qml/components/WalletDetails.qmlelectrum/simple_config.pyInspect captured patch +557 / −35
diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec
index 75328a5..4546181 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 0d34f70..3626c49 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 c0fddd9..68aab96 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 c8d3645..d5aa808 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() {
+ 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()
- dialog.close()
- })
- dialog.rejected.connect(function() {
+ } else {
qtobject.authCancel()
- })
- dialog.open()
- }
+ }
+ })
+ 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 0000000..dea680d
--- /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 0000000..8be4d3a
--- /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 a4df18b..640c84c 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 0000000..bcf46d3
--- /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 f76a8fb..5b2ef63 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 a748186..2623d77 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,
Why this scored 35/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.