1
0

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:
user
2025-11-30 20:04:32 +01:00
committed by f321x
parent 2529911df9
commit 5dd3dda238
10 changed files with 558 additions and 36 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

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

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

View File

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

View File

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

View File

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

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

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}')

View File

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