Merge pull request #10340 from f321x/fingerprint
android: implement biometric authentication (fingerprint)
This commit is contained in:
@@ -101,7 +101,7 @@ fullscreen = False
|
|||||||
#
|
#
|
||||||
|
|
||||||
# (list) Permissions
|
# (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)
|
# (int) Android API to use (compileSdkVersion)
|
||||||
# note: when changing, Dockerfile also needs to be changed to install corresponding build tools
|
# 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,
|
com.android.support:support-compat:28.0.0,
|
||||||
org.jetbrains.kotlin:kotlin-stdlib:1.8.22
|
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.
|
# (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 ':'
|
# The option may be used in three ways, the value may contain one or zero ':'
|
||||||
|
|||||||
@@ -5,7 +5,18 @@ from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
|||||||
from electrum.logging import get_logger
|
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:
|
if func is None:
|
||||||
return partial(auth_protect, reject=reject, method=method, message=message)
|
return partial(auth_protect, reject=reject, method=method, message=message)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -158,59 +158,83 @@ Pane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
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 {
|
Switch {
|
||||||
id: usePin
|
id: useBiometrics
|
||||||
checked: Config.pinCode
|
checked: Biometrics.isEnabled
|
||||||
onCheckedChanged: {
|
onCheckedChanged: {
|
||||||
if (activeFocus) {
|
if (activeFocus) {
|
||||||
console.log('PIN active ' + checked)
|
useBiometrics.focus = false
|
||||||
if (checked) {
|
if (checked) {
|
||||||
var dialog = pinSetup.createObject(preferences, {mode: 'enter'})
|
if (Daemon.singlePasswordEnabled) {
|
||||||
dialog.accepted.connect(function() {
|
Biometrics.enable(Daemon.singlePassword)
|
||||||
Config.pinCode = dialog.pincode
|
} else {
|
||||||
dialog.close()
|
useBiometrics.checked = false
|
||||||
})
|
var err = app.messageDialog.createObject(app, {
|
||||||
dialog.rejected.connect(function() {
|
title: qsTr('Unavailable'),
|
||||||
checked = false
|
text: [
|
||||||
})
|
qsTr("Cannot activate biometric authentication because you have wallets with different passwords."),
|
||||||
dialog.open()
|
qsTr("To use biometric authentication you first need to change all wallet passwords to the same password.")
|
||||||
|
].join("\n")
|
||||||
|
})
|
||||||
|
err.open()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
focus = false
|
Biometrics.disableProtected()
|
||||||
Config.pinCode = ''
|
|
||||||
// re-add binding, pincode still set if auth failed
|
|
||||||
checked = Qt.binding(function () { return Config.pinCode })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
text: qsTr('PIN protect payments')
|
text: qsTr('Biometric authentication')
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Pane {
|
RowLayout {
|
||||||
background: Rectangle { color: Material.dialogColor }
|
Layout.columnSpan: 2
|
||||||
padding: 0
|
Layout.fillWidth: true
|
||||||
visible: Config.pinCode != ''
|
spacing: 0
|
||||||
FlatButton {
|
|
||||||
text: qsTr('Modify')
|
property bool noWalletPassword: Daemon.currentWallet ? Daemon.currentWallet.verifyPassword('') : true
|
||||||
onClicked: {
|
enabled: Daemon.currentWallet && !noWalletPassword
|
||||||
var dialog = pinSetup.createObject(preferences, {
|
|
||||||
mode: 'change',
|
Switch {
|
||||||
pincode: Config.pinCode
|
id: paymentAuthentication
|
||||||
})
|
// showing the toggle as checked even if the wallet has no password would be misleading
|
||||||
dialog.accepted.connect(function() {
|
checked: Config.paymentAuthentication && !(Daemon.currentWallet && parent.noWalletPassword)
|
||||||
Config.pinCode = dialog.pincode
|
onCheckedChanged: {
|
||||||
dialog.close()
|
if (activeFocus) {
|
||||||
})
|
// will request authentication when checked = false
|
||||||
dialog.open()
|
console.log('paymentAuthentication: ' + checked)
|
||||||
|
Config.paymentAuthentication = checked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: qsTr('Payment authentication')
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -462,11 +486,6 @@ Pane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
|
||||||
id: pinSetup
|
|
||||||
Pin {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
language.currentIndex = language.indexOfValue(Config.language)
|
language.currentIndex = language.indexOfValue(Config.language)
|
||||||
baseUnit.currentIndex = _baseunits.indexOf(Config.baseUnit)
|
baseUnit.currentIndex = _baseunits.indexOf(Config.baseUnit)
|
||||||
|
|||||||
@@ -475,6 +475,15 @@ Pane {
|
|||||||
})
|
})
|
||||||
dialog.accepted.connect(function() {
|
dialog.accepted.connect(function() {
|
||||||
var success = Daemon.setPassword(dialog.password)
|
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, {
|
var done_dialog = app.messageDialog.createObject(app, {
|
||||||
title: success ? qsTr('Success') : qsTr('Error'),
|
title: success ? qsTr('Success') : qsTr('Error'),
|
||||||
iconSource: success
|
iconSource: success
|
||||||
@@ -546,6 +555,11 @@ Pane {
|
|||||||
}
|
}
|
||||||
var error_msg = qsTr('Password change failed')
|
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, {
|
var done_dialog = app.messageDialog.createObject(app, {
|
||||||
title: success ? qsTr('Success') : qsTr('Error'),
|
title: success ? qsTr('Success') : qsTr('Error'),
|
||||||
iconSource: success
|
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 {
|
Component {
|
||||||
id: importAddressesKeysDialog
|
id: importAddressesKeysDialog
|
||||||
ImportAddressesKeysDialog {
|
ImportAddressesKeysDialog {
|
||||||
|
|||||||
@@ -419,14 +419,6 @@ ApplicationWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
property alias pinDialog: _pinDialog
|
|
||||||
Component {
|
|
||||||
id: _pinDialog
|
|
||||||
Pin {
|
|
||||||
onClosed: destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
property alias genericShareDialog: _genericShareDialog
|
property alias genericShareDialog: _genericShareDialog
|
||||||
Component {
|
Component {
|
||||||
id: _genericShareDialog
|
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
|
property var _opendialog_startup: true
|
||||||
|
|
||||||
function showOpenWalletDialog(name, path) {
|
function showOpenWalletDialog(name, path) {
|
||||||
if (_opendialog == undefined) {
|
if (!_opendialog) {
|
||||||
_opendialog = openWalletDialog.createObject(app, {
|
_opendialog = openWalletDialog.createObject(app, {
|
||||||
name: name,
|
name: name,
|
||||||
path: path,
|
path: path,
|
||||||
isStartup: _opendialog_startup,
|
isStartup: _opendialog_startup,
|
||||||
})
|
})
|
||||||
_opendialog.closed.connect(function() {
|
_opendialog.closed.connect(function() {
|
||||||
_opendialog = undefined
|
_opendialog = null
|
||||||
|
app._loadingWalletContext = null // dialog closed, we can allow trying biometric auth again
|
||||||
_opendialog_startup = false
|
_opendialog_startup = false
|
||||||
})
|
})
|
||||||
_opendialog.open()
|
_opendialog.open()
|
||||||
@@ -655,7 +699,16 @@ ApplicationWindow
|
|||||||
target: Daemon
|
target: Daemon
|
||||||
function onWalletRequiresPassword(name, path) {
|
function onWalletRequiresPassword(name, path) {
|
||||||
console.log('wallet requires password')
|
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) {
|
function onWalletOpenError(error) {
|
||||||
console.log('wallet open error')
|
console.log('wallet open error')
|
||||||
@@ -676,6 +729,9 @@ ApplicationWindow
|
|||||||
var dialog = loadingWalletDialog.createObject(app, { allowClose: false } )
|
var dialog = loadingWalletDialog.createObject(app, { allowClose: false } )
|
||||||
dialog.open()
|
dialog.open()
|
||||||
}
|
}
|
||||||
|
function onWalletLoaded() {
|
||||||
|
app._loadingWalletContext = null // either biometric auth or manual auth was successful
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
@@ -761,53 +817,52 @@ ApplicationWindow
|
|||||||
function handleAuthRequired(qtobject, method, authMessage) {
|
function handleAuthRequired(qtobject, method, authMessage) {
|
||||||
console.log('auth using method ' + method)
|
console.log('auth using method ' + method)
|
||||||
|
|
||||||
if (method == 'wallet_else_pin') {
|
if (method === 'payment_auth') {
|
||||||
// if there is a loaded wallet and all wallets use the same password, use that
|
if (Config.paymentAuthentication) {
|
||||||
// else delegate to pin auth
|
// treat like a wallet auth request
|
||||||
if (Daemon.currentWallet && Daemon.singlePasswordEnabled) {
|
|
||||||
method = 'wallet'
|
method = 'wallet'
|
||||||
} else {
|
} else {
|
||||||
method = 'pin'
|
handleAuthConfirmationOnly(qtobject, authMessage)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method == 'wallet') {
|
if (Daemon.currentWallet.verifyPassword('')) {
|
||||||
if (Daemon.currentWallet.verifyPassword('')) {
|
// wallet has no password
|
||||||
// wallet has no password
|
qtobject.authProceed()
|
||||||
qtobject.authProceed()
|
return
|
||||||
} else {
|
}
|
||||||
var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')})
|
|
||||||
dialog.accepted.connect(function() {
|
if (method !== 'wallet_password_only') {
|
||||||
if (Daemon.currentWallet.verifyPassword(dialog.password)) {
|
if (Biometrics.isAvailable && Biometrics.isEnabled) {
|
||||||
qtobject.authProceed()
|
app._pendingBiometricAuth = {
|
||||||
} else {
|
qtobject: qtobject,
|
||||||
qtobject.authCancel()
|
method: method,
|
||||||
}
|
|
||||||
})
|
|
||||||
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,
|
|
||||||
authMessage: authMessage
|
authMessage: authMessage
|
||||||
})
|
}
|
||||||
dialog.accepted.connect(function() {
|
Biometrics.unlock(authMessage)
|
||||||
qtobject.authProceed()
|
return
|
||||||
dialog.close()
|
|
||||||
})
|
|
||||||
dialog.rejected.connect(function() {
|
|
||||||
qtobject.authCancel()
|
|
||||||
})
|
|
||||||
dialog.open()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
console.log('unknown auth method ' + method)
|
console.log('unknown auth method ' + method)
|
||||||
qtobject.authCancel()
|
qtobject.authCancel()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ from .qeswaphelper import QESwapHelper
|
|||||||
from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
|
from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
|
||||||
from .qemodelfilter import QEFilterProxyModel
|
from .qemodelfilter import QEFilterProxyModel
|
||||||
from .qebip39recovery import QEBip39RecoveryListModel
|
from .qebip39recovery import QEBip39RecoveryListModel
|
||||||
|
from .qebiometrics import QEBiometrics
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from electrum.simple_config import SimpleConfig
|
from electrum.simple_config import SimpleConfig
|
||||||
@@ -537,6 +538,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
|||||||
self.daemon = QEDaemon(daemon, self.plugins)
|
self.daemon = QEDaemon(daemon, self.plugins)
|
||||||
self.appController = QEAppController(self, self.plugins)
|
self.appController = QEAppController(self, self.plugins)
|
||||||
self.maxAmount = QEAmount(is_max=True)
|
self.maxAmount = QEAmount(is_max=True)
|
||||||
|
self.biometrics = QEBiometrics(config=config, parent=self)
|
||||||
self.context.setContextProperty('AppController', self.appController)
|
self.context.setContextProperty('AppController', self.appController)
|
||||||
self.context.setContextProperty('Config', self.config)
|
self.context.setContextProperty('Config', self.config)
|
||||||
self.context.setContextProperty('Network', self.network)
|
self.context.setContextProperty('Network', self.network)
|
||||||
@@ -544,6 +546,7 @@ class ElectrumQmlApplication(QGuiApplication):
|
|||||||
self.context.setContextProperty('FixedFont', self.fixedFont)
|
self.context.setContextProperty('FixedFont', self.fixedFont)
|
||||||
self.context.setContextProperty('MAX', self.maxAmount)
|
self.context.setContextProperty('MAX', self.maxAmount)
|
||||||
self.context.setContextProperty('QRIP', self.qr_ip_h)
|
self.context.setContextProperty('QRIP', self.qr_ip_h)
|
||||||
|
self.context.setContextProperty('Biometrics', self.biometrics)
|
||||||
self.context.setContextProperty('BUILD', {
|
self.context.setContextProperty('BUILD', {
|
||||||
'electrum_version': version.ELECTRUM_VERSION,
|
'electrum_version': version.ELECTRUM_VERSION,
|
||||||
'protocol_version': f"[{version.PROTOCOL_VERSION_MIN}, {version.PROTOCOL_VERSION_MAX}]",
|
'protocol_version': f"[{version.PROTOCOL_VERSION_MIN}, {version.PROTOCOL_VERSION_MAX}]",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -153,23 +153,26 @@ class QEConfig(AuthMixin, QObject):
|
|||||||
self.config.WALLET_PAYREQ_EXPIRY_SECONDS = expiry
|
self.config.WALLET_PAYREQ_EXPIRY_SECONDS = expiry
|
||||||
self.requestExpiryChanged.emit()
|
self.requestExpiryChanged.emit()
|
||||||
|
|
||||||
pinCodeChanged = pyqtSignal()
|
paymentAuthenticationChanged = pyqtSignal()
|
||||||
@pyqtProperty(str, notify=pinCodeChanged)
|
@pyqtProperty(bool, notify=paymentAuthenticationChanged)
|
||||||
def pinCode(self):
|
def paymentAuthentication(self):
|
||||||
return self.config.CONFIG_PIN_CODE or ""
|
return self.config.GUI_QML_PAYMENT_AUTHENTICATION
|
||||||
|
|
||||||
@pinCode.setter
|
@paymentAuthentication.setter
|
||||||
def pinCode(self, pin_code):
|
def paymentAuthentication(self, enabled: bool):
|
||||||
if pin_code == '':
|
if enabled:
|
||||||
self.pinCodeRemoveAuth()
|
self.config.GUI_QML_PAYMENT_AUTHENTICATION = True
|
||||||
|
self.paymentAuthenticationChanged.emit()
|
||||||
else:
|
else:
|
||||||
self.config.CONFIG_PIN_CODE = pin_code
|
self._disable_payment_authentication()
|
||||||
self.pinCodeChanged.emit()
|
|
||||||
|
|
||||||
@auth_protect(method='wallet_else_pin')
|
@auth_protect(method='wallet', reject='_payment_auth_reject')
|
||||||
def pinCodeRemoveAuth(self):
|
def _disable_payment_authentication(self):
|
||||||
self.config.CONFIG_PIN_CODE = ""
|
self.config.GUI_QML_PAYMENT_AUTHENTICATION = False
|
||||||
self.pinCodeChanged.emit()
|
self.paymentAuthenticationChanged.emit()
|
||||||
|
|
||||||
|
def _payment_auth_reject(self):
|
||||||
|
self.paymentAuthenticationChanged.emit()
|
||||||
|
|
||||||
useGossipChanged = pyqtSignal()
|
useGossipChanged = pyqtSignal()
|
||||||
@pyqtProperty(bool, notify=useGossipChanged)
|
@pyqtProperty(bool, notify=useGossipChanged)
|
||||||
|
|||||||
@@ -232,6 +232,13 @@ class QEDaemon(AuthMixin, QObject):
|
|||||||
|
|
||||||
if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD:
|
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)
|
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._password = local_password
|
||||||
self.singlePasswordChanged.emit()
|
self.singlePasswordChanged.emit()
|
||||||
self._logger.info(f'use single password: {self._use_single_password}')
|
self._logger.info(f'use single password: {self._use_single_password}')
|
||||||
@@ -381,7 +388,7 @@ class QEDaemon(AuthMixin, QObject):
|
|||||||
return f'wallet_{i}'
|
return f'wallet_{i}'
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
@auth_protect(method='wallet')
|
@auth_protect(method='wallet_password_only')
|
||||||
def startChangePassword(self):
|
def startChangePassword(self):
|
||||||
if self._use_single_password:
|
if self._use_single_password:
|
||||||
self.requestNewPassword.emit()
|
self.requestNewPassword.emit()
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ if 'ANDROID_DATA' in os.environ:
|
|||||||
|
|
||||||
|
|
||||||
class QEQRScanner(QObject):
|
class QEQRScanner(QObject):
|
||||||
|
REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
foundText = pyqtSignal(str)
|
foundText = pyqtSignal(str)
|
||||||
@@ -54,7 +56,7 @@ class QEQRScanner(QObject):
|
|||||||
intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint))
|
intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint))
|
||||||
|
|
||||||
activity.bind(on_activity_result=self.on_qr_activity_result)
|
activity.bind(on_activity_result=self.on_qr_activity_result)
|
||||||
jpythonActivity.startActivityForResult(intent, 0)
|
jpythonActivity.startActivityForResult(intent, self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -62,6 +64,9 @@ class QEQRScanner(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def on_qr_activity_result(self, requestCode, resultCode, intent):
|
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:
|
try:
|
||||||
if resultCode == -1: # RESULT_OK:
|
if resultCode == -1: # RESULT_OK:
|
||||||
if (contents := intent.getStringExtra(jString("text"))) is not None:
|
if (contents := intent.getStringExtra(jString("text"))) is not None:
|
||||||
|
|||||||
@@ -679,6 +679,11 @@ class SimpleConfig(Logger):
|
|||||||
WALLET_SHOULD_USE_SINGLE_PASSWORD = ConfigVar('should_use_single_password', default=False, type_=bool)
|
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
|
# 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_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
|
# note: 'use_change' and 'multiple_change' are per-wallet settings
|
||||||
WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar(
|
WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar(
|
||||||
'send_change_to_lightning', default=False, type_=bool,
|
'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_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_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_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_DECIMAL_POINT = ConfigVar('decimal_point', default=DECIMAL_POINT_DEFAULT, type_=int)
|
||||||
BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT = ConfigVar(
|
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)
|
RECENTLY_OPEN_WALLET_FILES = ConfigVar('recently_open', default=None)
|
||||||
IO_DIRECTORY = ConfigVar('io_dir', default=os.path.expanduser('~'), type_=str)
|
IO_DIRECTORY = ConfigVar('io_dir', default=os.path.expanduser('~'), type_=str)
|
||||||
WALLET_BACKUP_DIRECTORY = ConfigVar('backup_dir', default=None, 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)
|
QR_READER_FLIP_X = ConfigVar('qrreader_flip_x', default=True, type_=bool)
|
||||||
WIZARD_DONT_CREATE_SEGWIT = ConfigVar('nosegwit', default=False, type_=bool)
|
WIZARD_DONT_CREATE_SEGWIT = ConfigVar('nosegwit', default=False, type_=bool)
|
||||||
CONFIG_FORGET_CHANGES = ConfigVar('forget_config', default=False, type_=bool)
|
CONFIG_FORGET_CHANGES = ConfigVar('forget_config', default=False, type_=bool)
|
||||||
|
|||||||
Reference in New Issue
Block a user