Files
purple-electrumwallet/electrum/gui/qml/qebiometrics.py
T
f321x f573ab2d56 android: biometry: catch java import errors
Catch JavaError when trying to load the java classes of the biometry
module on startup. This can raise if the device is on an old API version
and the loaded java class depends on apis unknown to the os.

Fixes #10470

```
02-17 10:07:25.714  5254  5270 I python  :   0.47 | E | __main__ | daemon.run_gui errored
02-17 10:07:25.714  5254  5270 I python  : Traceback (most recent call last):
02-17 10:07:25.714  5254  5270 I python  :   File "app/main.py", line 514, in handle_cmd
02-17 10:07:25.714  5254  5270 I python  :   File "app/electrum/daemon.py", line 653, in run_gui
02-17 10:07:25.714  5254  5270 I python  :   File "app/electrum/gui/qml/__init__.py", line 38, in <module>
02-17 10:07:25.714  5254  5270 I python  :   File "app/electrum/gui/qml/qeapp.py", line 49, in <module>
02-17 10:07:25.714  5254  5270 I python  :   File "app/electrum/gui/qml/qebiometrics.py", line 33, in <module>
02-17 10:07:25.714  5254  5270 I python  :   File "jnius/reflect.py", line 243, in autoclass
02-17 10:07:25.714  5254  5270 I python  :   File "jnius/jnius_export_class.pxi", line 877, in jnius.jnius.JavaMethod.__call__
02-17 10:07:25.714  5254  5270 I python  :   File "jnius/jnius_export_class.pxi", line 964, in jnius.jnius.JavaMethod.call_method
02-17 10:07:25.714  5254  5270 I python  :   File "jnius/jnius_utils.pxi", line 79, in jnius.jnius.check_exception
02-17 10:07:25.714  5254  5270 I python  : jnius.jnius.JavaException: JVM exception occurred: Failed resolution of: Landroid/hardware/biometrics/BiometricPrompt$AuthenticationResult; java.lang.NoClassDefFoundError
```
2026-02-17 10:18:21 +01:00

205 lines
8.7 KiB
Python

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, JavaException
from android import activity
try:
jPythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
jIntent = autoclass('android.content.Intent')
jString = autoclass('java.lang.String')
jBiometricActivity = autoclass('org.electrum.biometry.BiometricActivity')
jBiometricHelper = autoclass('org.electrum.biometry.BiometricHelper')
except JavaException as e:
_logger.error(f"Could not load Biometric java classes (maybe due to old api version): {e}")
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 or jBiometricHelper is None:
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()