1
0

Merge pull request #10345 from f321x/enforce_unified_password_qml_button

qml: limit creation of new wallets to existing password
This commit is contained in:
ThomasV
2025-12-18 19:25:04 +01:00
committed by GitHub
13 changed files with 268 additions and 99 deletions

View File

@@ -564,6 +564,8 @@ class Daemon(Logger):
if os.path.exists(path):
os.unlink(path)
self.update_recently_opened_wallets(path, remove=True)
if self.config.CURRENT_WALLET == path:
self.config.CURRENT_WALLET = None
return True
return False
@@ -665,11 +667,12 @@ class Daemon(Logger):
asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
@with_wallet_lock
def _check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool]:
def check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool, list[str]]:
"""Checks password against all wallets (in dir), returns whether they can be unified and whether they are already.
If new_password is not None, update all wallet passwords to new_password.
"""
assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist"
succeeded = []
failed = []
is_unified = True
for filename in os.listdir(wallet_dir):
@@ -708,9 +711,11 @@ class Daemon(Logger):
if new_password:
self.logger.info(f'updating password for wallet: {path!r}')
wallet.update_password(old_password_real, new_password, encrypt_storage=True)
succeeded.append(path)
can_be_unified = failed == []
is_unified = can_be_unified and is_unified
return can_be_unified, is_unified
return can_be_unified, is_unified, succeeded
@with_wallet_lock
def update_password_for_directory(
@@ -726,13 +731,13 @@ class Daemon(Logger):
return False
if wallet_dir is None:
wallet_dir = os.path.dirname(self.config.get_wallet_path())
can_be_unified, is_unified = self._check_password_for_directory(
can_be_unified, is_unified, _ = self.check_password_for_directory(
old_password=old_password, new_password=None, wallet_dir=wallet_dir)
if not can_be_unified:
return False
if is_unified and old_password == new_password:
return True
self._check_password_for_directory(
self.check_password_for_directory(
old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
return True

View File

@@ -44,7 +44,13 @@ ElDialog {
console.log('daemon loading ' + Daemon.loading)
if (!Daemon.loading) {
showTimer.stop()
dialog.close()
if (dialog.visible) {
dialog.close()
} else {
// if the dialog wasn't visible its onClosed callbacks don't get called, so it
// needs to be destroyed manually
Qt.callLater(function() { dialog.destroy() })
}
}
}
}

View File

@@ -12,6 +12,7 @@ ElDialog {
property string name
property string path
property bool isStartup
property bool _invalidPassword: false
property bool _unlockClicked: false
@@ -40,7 +41,7 @@ ElDialog {
InfoTextArea {
id: notice
text: Daemon.singlePasswordEnabled || !Daemon.currentWallet
text: Daemon.singlePasswordEnabled || isStartup
? qsTr('Please enter password')
: qsTr('Wallet <b>%1</b> requires password to unlock').arg(name)
iconStyle: InfoTextArea.IconStyle.Warn
@@ -94,9 +95,39 @@ ElDialog {
Daemon.loadWallet(openwalletdialog.path, password.text)
}
function maybeUnlockAnyOtherWallet() {
// try to open any other wallet with the password the user entered, hack to improve ux for
// users with non-unified wallet password.
// we should only fall back to opening a random wallet if:
// - the user did not select a specific wallet, otherwise this is confusing
// - there can be more than one password, otherwise this scan would be pointless
if (Daemon.availableWallets.rowCount() <= 1 || password.text === '') {
return false
}
if (Config.walletDidUseSinglePassword) {
// the last time the wallet was unlocked all wallets used the same password.
// trying to decrypt all of them now is most probably useless.
return false
}
if (!openwalletdialog.isStartup) {
return false // this dialog got opened because the user clicked on a specific wallet
}
let wallet_paths = Daemon.getWalletsUnlockableWithPassword(password.text)
if (wallet_paths && wallet_paths.length > 0) {
console.log('could not unlock recent wallet, falling back to: ' + wallet_paths[0])
Daemon.loadWallet(wallet_paths[0], password.text)
return true
}
return false
}
Connections {
target: Daemon
function onWalletRequiresPassword() {
if (maybeUnlockAnyOtherWallet()) {
password.text = '' // reset pw so we cannot end up in a loop
return
}
console.log('invalid password')
_invalidPassword = true
password.tf.forceActiveFocus()

View File

@@ -525,15 +525,33 @@ Pane {
confirmPassword: true,
title: qsTr('Enter new password'),
infotext: qsTr('If you forget your password, you\'ll need to restore from seed. Please make sure you have your seed stored safely')
+ (Daemon.availableWallets.rowCount() > 1 && Config.walletShouldUseSinglePassword
? "\n\n" + qsTr('The new password needs to match the password of any other existing wallet.')
: "")
})
dialog.accepted.connect(function() {
var success = Daemon.currentWallet.setPassword(dialog.password)
if (Config.walletShouldUseSinglePassword // android
&& Daemon.availableWallets.rowCount() > 1 // has more than one wallet
&& Daemon.numWalletsWithPassword(dialog.password) < 1 // no other wallet uses this new password
) {
var success = false
var error_msg = [
qsTr('You need to use the password of any other existing wallet.'),
qsTr('Using different wallet passwords is not supported.'),
].join("\n")
} else {
var success = Daemon.currentWallet.setPassword(dialog.password)
if (success && Config.walletShouldUseSinglePassword) {
Daemon.singlePassword = dialog.password
}
var error_msg = qsTr('Password change failed')
}
var done_dialog = app.messageDialog.createObject(app, {
title: success ? qsTr('Success') : qsTr('Error'),
iconSource: success
? Qt.resolvedUrl('../../icons/info.png')
: Qt.resolvedUrl('../../icons/warning.png'),
text: success ? qsTr('Password changed') : qsTr('Password change failed')
text: success ? qsTr('Password changed') : error_msg
})
done_dialog.open()
})

View File

@@ -11,10 +11,9 @@ import "controls"
Item {
id: mainView
property string title: Daemon.currentWallet ? Daemon.currentWallet.name : qsTr('no wallet loaded')
property string title: Daemon.currentWallet.name
property var _sendDialog
property string _intentUri
property string _request_amount
property string _request_description
@@ -188,7 +187,7 @@ Item {
icon.source: '../../icons/wallet.png'
action: Action {
text: qsTr('Wallet details')
enabled: Daemon.currentWallet && app.stack.currentItem.objectName != 'WalletDetails'
enabled: app.stack.currentItem.objectName != 'WalletDetails'
onTriggered: menu.openPage(Qt.resolvedUrl('WalletDetails.qml'))
}
}
@@ -198,7 +197,7 @@ Item {
action: Action {
text: qsTr('Addresses/Coins');
onTriggered: menu.openPage(Qt.resolvedUrl('Addresses.qml'));
enabled: Daemon.currentWallet && app.stack.currentItem.objectName != 'Addresses'
enabled: app.stack.currentItem.objectName != 'Addresses'
}
}
MenuItem {
@@ -206,7 +205,7 @@ Item {
icon.source: '../../icons/lightning.png'
action: Action {
text: qsTr('Channels');
enabled: Daemon.currentWallet && Daemon.currentWallet.isLightning && app.stack.currentItem.objectName != 'Channels'
enabled: Daemon.currentWallet.isLightning && app.stack.currentItem.objectName != 'Channels'
onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml'))
}
}
@@ -285,62 +284,16 @@ Item {
History {
id: history
visible: Daemon.currentWallet
Layout.fillWidth: true
Layout.fillHeight: true
}
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
spacing: 2*constants.paddingXLarge
visible: !Daemon.currentWallet
Item {
Layout.fillHeight: true
}
Label {
Layout.alignment: Qt.AlignHCenter
text: qsTr('No wallet loaded')
font.pixelSize: constants.fontSizeXXLarge
}
Pane {
Layout.alignment: Qt.AlignHCenter
padding: 0
background: Rectangle {
color: Material.dialogColor
}
FlatButton {
text: qsTr('Open/Create Wallet')
icon.source: '../../icons/wallet.png'
onClicked: {
if (Daemon.availableWallets.rowCount() > 0) {
stack.push(Qt.resolvedUrl('Wallets.qml'))
} else {
var newww = app.newWalletWizard.createObject(app)
newww.walletCreated.connect(function() {
Daemon.availableWallets.reload()
// and load the new wallet
Daemon.loadWallet(newww.path, newww.wizard_data['password'])
})
newww.open()
}
}
}
}
Item {
Layout.fillHeight: true
}
}
ButtonContainer {
id: buttonContainer
Layout.fillWidth: true
FlatButton {
id: receiveButton
visible: Daemon.currentWallet
Layout.fillWidth: true
Layout.preferredWidth: 1
icon.source: '../../icons/tab_receive.png'
@@ -357,7 +310,6 @@ Item {
}
}
FlatButton {
visible: Daemon.currentWallet
Layout.fillWidth: true
Layout.preferredWidth: 1
icon.source: '../../icons/tab_send.png'
@@ -489,25 +441,22 @@ Item {
}
Connections {
target: AppController
function onUriReceived(uri) {
console.log('uri received: ' + uri)
if (!Daemon.currentWallet) {
console.log('No wallet open, deferring')
_intentUri = uri
target: Daemon
function onWalletLoaded() {
if (!Daemon.currentWallet) { // wallet got deleted
app.stack.replaceRoot('Wallets.qml')
return
}
piResolver.recipient = uri
infobanner.hide() // start hidden when switching wallets
}
}
Connections {
target: Daemon
function onWalletLoaded() {
infobanner.hide() // start hidden when switching wallets
if (_intentUri) {
piResolver.recipient = _intentUri
_intentUri = ''
target: app
function onPendingIntentChanged() {
if (app.pendingIntent) {
piResolver.recipient = app.pendingIntent
app.pendingIntent = ""
}
}
}
@@ -819,5 +768,12 @@ Item {
}
}
Component.onCompleted: {
console.log("WalletMainView completed: ", Daemon.currentWallet.name)
if (app.pendingIntent) {
piResolver.recipient = app.pendingIntent
app.pendingIntent = ""
}
}
}

View File

@@ -121,7 +121,21 @@ Pane {
Layout.fillWidth: true
text: qsTr('Create Wallet')
icon.source: '../../icons/add.png'
onClicked: rootItem.createWallet()
onClicked: {
if (Daemon.availableWallets.rowCount() > 0 && Config.walletShouldUseSinglePassword
&& (!Daemon.singlePassword || Daemon.numWalletsWithPassword(Daemon.singlePassword) < 1)) {
// if the user has wallets but hasn't unlocked any wallet yet force them to do so.
// this ensures they know at least one wallets password and can complete the wizard
// where they will need to enter the password of an existing wallet.
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Wallet unlock required'),
text: qsTr("You have to unlock any existing wallet first before creating a new wallet."),
})
dialog.open()
} else {
rootItem.createWallet()
}
}
}
}
@@ -129,7 +143,11 @@ Pane {
target: Daemon
function onWalletLoaded() {
if (app.stack.currentItem.objectName == 'Wallets')
app.stack.pop()
if (app.stack.getRoot().objectName == 'Wallets') {
app.stack.replaceRoot('WalletMainView.qml')
} else {
app.stack.pop()
}
}
}

View File

@@ -38,6 +38,8 @@ ApplicationWindow
property alias keyboardFreeZone: _keyboardFreeZone
property alias infobanner: _infobanner
property string pendingIntent: ""
property variant activeDialogs: []
property var _exceptionDialog
@@ -120,7 +122,7 @@ ApplicationWindow
header: ToolBar {
id: toolbar
// Add top margin for status bar on Android when using edge-to-edge
topPadding: app.statusBarHeight
@@ -269,7 +271,7 @@ ApplicationWindow
Layout.fillWidth: true
initialItem: Component {
WalletMainView {}
Wallets {}
}
function getRoot() {
@@ -282,6 +284,10 @@ ApplicationWindow
mainStackView.push(item)
}
}
function replaceRoot(item_url) {
mainStackView.clear()
mainStackView.push(Qt.resolvedUrl(item_url))
}
}
// Add bottom padding for navigation bar on Android when UI is edge-to-edge
@@ -628,12 +634,18 @@ ApplicationWindow
}
property var _opendialog: undefined
property var _opendialog_startup: true
function showOpenWalletDialog(name, path) {
if (_opendialog == undefined) {
_opendialog = openWalletDialog.createObject(app, { name: name, path: path })
_opendialog = openWalletDialog.createObject(app, {
name: name,
path: path,
isStartup: _opendialog_startup,
})
_opendialog.closed.connect(function() {
_opendialog = undefined
_opendialog_startup = false
})
_opendialog.open()
}
@@ -698,6 +710,10 @@ ApplicationWindow
if (obj != null)
app.pluginobjects[name] = obj
}
function onUriReceived(uri) {
console.log('uri received (main): ' + uri)
app.pendingIntent = uri
}
}
function pluginsComponentsByName(comp_name) {

View File

@@ -5,37 +5,70 @@ import QtQuick.Controls.Material
import "../controls"
// We will only end up here if Daemon.singlePasswordEnabled == False.
// If there are existing wallets, the user must reuse the password of one of them.
// This way they are guided towards password unification.
// NOTE: This also needs to be enforced when changing a wallets password.
WizardComponent {
valid: password1.text === password2.text && password1.text.length >= 6
id: root
valid: isInputValid()
property bool enforceExistingPassword: Config.walletShouldUseSinglePassword && Daemon.availableWallets.rowCount() > 0
property bool passwordMatchesAnyExisting: false
function apply() {
wizard_data['password'] = password1.text
wizard_data['encrypt'] = password1.text != ''
}
function isInputValid() {
if (password1.text == "") {
return false
}
if (enforceExistingPassword) {
return passwordMatchesAnyExisting
}
return password1.text === password2.text && password1.text.length >= 6
}
Timer {
id: passwordComparisonTimer
interval: 500
repeat: false
onTriggered: {
root.passwordMatchesAnyExisting = Daemon.numWalletsWithPassword(password1.text) > 0
}
}
ColumnLayout {
anchors.fill: parent
Label {
Layout.fillWidth: true
text: Daemon.singlePasswordEnabled
? qsTr('Enter password')
: qsTr('Enter password for %1').arg(wizard_data['wallet_name'])
text: !enforceExistingPassword ? qsTr('Enter password') : qsTr('Enter existing password')
wrapMode: Text.Wrap
}
PasswordField {
id: password1
onTextChanged: {
if (enforceExistingPassword) {
root.passwordMatchesAnyExisting = false
passwordComparisonTimer.restart()
}
}
}
Label {
text: qsTr('Enter password (again)')
visible: !enforceExistingPassword
}
PasswordField {
id: password2
showReveal: false
echoMode: password1.echoMode
visible: !enforceExistingPassword
}
RowLayout {
@@ -44,7 +77,7 @@ WizardComponent {
Layout.rightMargin: constants.paddingXLarge
Layout.topMargin: constants.paddingXLarge
visible: password1.text != ''
visible: password1.text != '' && !enforceExistingPassword
Label {
Layout.rightMargin: constants.paddingLarge
@@ -65,13 +98,30 @@ WizardComponent {
InfoTextArea {
Layout.alignment: Qt.AlignCenter
text: qsTr('Passwords don\'t match')
visible: password1.text != password2.text
visible: (password1.text != password2.text) && !enforceExistingPassword
iconStyle: InfoTextArea.IconStyle.Warn
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
text: qsTr('Password too short')
visible: (password1.text == password2.text) && !valid
visible: (password1.text == password2.text) && !valid && !enforceExistingPassword
iconStyle: InfoTextArea.IconStyle.Warn
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
visible: password1.text == "" && enforceExistingPassword
text: [
qsTr("Use the password of any existing wallet."),
qsTr("Creating new wallets with different passwords is not supported.")
].join("\n")
iconStyle: InfoTextArea.IconStyle.Info
}
InfoTextArea {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
visible: password1.text != "" && !valid && enforceExistingPassword
text: qsTr('Password does not match any existing wallets password.')
iconStyle: InfoTextArea.IconStyle.Warn
}
}

View File

@@ -331,6 +331,25 @@ class QEConfig(AuthMixin, QObject):
self._lnutxoreserve = QEAmount(amount_sat=self.config.LN_UTXO_RESERVE)
return self._lnutxoreserve
walletShouldUseSinglePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=walletShouldUseSinglePasswordChanged)
def walletShouldUseSinglePassword(self):
"""
NOTE: this only indicates if we even want to use a single password, to check if we
actually use a single password the daemon needs to be checked.
"""
return self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
walletDidUseSinglePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=walletDidUseSinglePasswordChanged)
def walletDidUseSinglePassword(self):
"""
Allows to guess if this is a unified password instance without having
unlocked any wallet yet. Might be out of sync e.g. if wallet files get copied manually.
"""
# TODO: consider removing once encrypted wallet file headers are available
return self.config.WALLET_DID_USE_SINGLE_PASSWORD
@pyqtSlot('qint64', result=str)
@pyqtSlot(QEAmount, result=str)
def formatSatsForEditing(self, satoshis):

View File

@@ -98,7 +98,7 @@ class QEWalletListModel(QAbstractListModel):
i += 1
if remove >= 0:
self.beginRemoveRows(QModelIndex(), i, i)
self.beginRemoveRows(QModelIndex(), remove, remove)
self._wallets = wallets
self.endRemoveRows()
@@ -219,7 +219,7 @@ class QEDaemon(AuthMixin, QObject):
except InvalidPassword:
self.walletRequiresPassword.emit(self._name, self._path)
except FileNotFoundError:
self.walletOpenError.emit(_('File not found'))
self.walletOpenError.emit(_('File not found') + f":\n{self._path}")
except StorageReadWriteError:
self.walletOpenError.emit(_('Could not read/write file'))
except WalletFileException as e:
@@ -230,13 +230,14 @@ class QEDaemon(AuthMixin, QObject):
if wallet is None:
return
if self.daemon.config.WALLET_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._password = local_password
self.singlePasswordChanged.emit()
self._logger.info(f'use single password: {self._use_single_password}')
else:
self._logger.info('use single password disabled by config')
self.daemon.config.WALLET_DID_USE_SINGLE_PASSWORD = self._use_single_password
run_hook('load_wallet', wallet)
@@ -318,15 +319,59 @@ class QEDaemon(AuthMixin, QObject):
def fx(self):
return self.qefx
@pyqtSlot(str, result=list)
def getWalletsUnlockableWithPassword(self, password: str) -> list[str]:
"""
Returns any wallet that can be unlocked with the given password.
Can be used as fallback to unlock another wallet the user entered a
password that doesn't work for the current wallet but might work for another one.
"""
wallet_dir = os.path.dirname(self.daemon.config.get_wallet_path())
_, _, wallet_paths_can_unlock = self.daemon.check_password_for_directory(
old_password=password,
new_password=None,
wallet_dir=wallet_dir,
)
if not wallet_paths_can_unlock:
return []
self._logger.debug(f"getWalletsUnlockableWithPassword: can unlock {len(wallet_paths_can_unlock)} wallets")
return [str(path) for path in wallet_paths_can_unlock]
@pyqtSlot(str, result=int)
def numWalletsWithPassword(self, password: str) -> int:
"""Returns the number of wallets that can be unlocked with the given password"""
wallet_paths_can_unlock = self.getWalletsUnlockableWithPassword(password)
return len(wallet_paths_can_unlock)
singlePasswordChanged = pyqtSignal()
@pyqtProperty(bool, notify=singlePasswordChanged)
def singlePasswordEnabled(self):
"""
singlePasswordEnabled is False if:
a.) the user has no wallet (and password) yet
b.) the user has wallets with different passwords (legacy)
c.) all wallets are locked, we couldn't check yet if they all use the same password
d.) we are on desktop where different passwords are allowed
"""
return self._use_single_password
@pyqtProperty(str, notify=singlePasswordChanged)
def singlePassword(self):
"""
self._password is also set to the last loaded wallet password if we WANT a single password,
but don't actually have a single password yet. So singlePassword being set doesn't strictly
mean all wallets use the same password.
"""
return self._password
@singlePassword.setter
def singlePassword(self, password: str):
assert password
assert self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
if self._password != password:
self._password = password
self.singlePasswordChanged.emit()
@pyqtSlot(result=str)
def suggestWalletName(self):
# FIXME why not use util.get_new_wallet_name ?

View File

@@ -675,7 +675,9 @@ class SimpleConfig(Logger):
)
WALLET_UNCONF_UTXO_FREEZE_THRESHOLD_SAT = ConfigVar('unconf_utxo_freeze_threshold', default=5_000, type_=int)
WALLET_PAYREQ_EXPIRY_SECONDS = ConfigVar('request_expiry', default=invoices.PR_DEFAULT_EXPIRATION_WHEN_CREATING, type_=int)
WALLET_USE_SINGLE_PASSWORD = ConfigVar('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
WALLET_DID_USE_SINGLE_PASSWORD = ConfigVar('did_use_single_password', default=False, type_=bool)
# note: 'use_change' and 'multiple_change' are per-wallet settings
WALLET_SEND_CHANGE_TO_LIGHTNING = ConfigVar(
'send_change_to_lightning', default=False, type_=bool,