android: implement biometric authentication
Allows to unlock the android app with the android biometric api (e.g. fingerprint). Can be enabled in the settings.
This commit is contained in:
@@ -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 ':'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
qtobject.authProceed()
|
||||
dialog.close()
|
||||
})
|
||||
dialog.rejected.connect(function() {
|
||||
qtobject.authCancel()
|
||||
})
|
||||
dialog.open()
|
||||
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()
|
||||
} else {
|
||||
qtobject.authCancel()
|
||||
}
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}]",
|
||||
|
||||
181
electrum/gui/qml/qebiometrics.py
Normal file
181
electrum/gui/qml/qebiometrics.py
Normal file
@@ -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()
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user