1
0

Merge pull request #10340 from f321x/fingerprint

android: implement biometric authentication (fingerprint)
This commit is contained in:
ghost43
2026-01-19 15:22:17 +00:00
committed by GitHub
14 changed files with 645 additions and 225 deletions

View File

@@ -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 ':'

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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}]",

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)