diff --git a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml index f53f95a10bb..cb30e268f94 100644 --- a/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml +++ b/electrum/gui/qml/components/wizard/WCHaveMasterKey.qml @@ -27,6 +27,8 @@ WizardComponent { wizard_data['multisig_cosigner_data'][cosigner.toString()]['master_key'] = key } else { wizard_data['master_key'] = key + wizard_data['key_origin_derivation'] = derivation_tf.text.trim() + wizard_data['key_origin_fingerprint'] = fingerprint_tf.text.trim().toLowerCase() } } @@ -41,7 +43,7 @@ WizardComponent { } if (!bitcoin.verifyMasterKey(key, wizard_data['wallet_type'])) { - validationtext.text = qsTr('Error: invalid master key') + validationtext.text = bitcoin.validationMessage return false } @@ -60,6 +62,28 @@ WizardComponent { return valid = true } + function validateKeyOrigin() { + if (cosigner || wizard_data['wallet_type'] === 'multisig') + return true + if (bitcoin.masterKeyDepth(masterkey_ta.text.trim()) <= 1) + return true + var msg = bitcoin.verifyKeyOriginInfo( + masterkey_ta.text.trim(), + derivation_tf.text.trim(), + fingerprint_tf.text.trim() + ) + keyOriginError.text = msg + return msg === '' + } + + function revalidate() { + var keyOk = verifyMasterKey(masterkey_ta.text) + var originOk = validateKeyOrigin() + valid = keyOk && originOk + if (keyOk) + apply() + } + ColumnLayout { width: parent.width @@ -120,7 +144,7 @@ WizardComponent { qsTr('Enter their master private key (xprv) if you want to be able to sign for them.') ].join('\n') : [qsTr('Please enter your master private key (xprv).'), - qsTr('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys') + qsTr('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys.') ].join('\n') wrapMode: Text.Wrap } @@ -134,7 +158,7 @@ WizardComponent { wrapMode: TextEdit.WrapAnywhere onTextChanged: { if (anyActiveFocus) { - verifyMasterKey(text) + revalidate() } } inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase @@ -188,6 +212,63 @@ WizardComponent { color: 'transparent' } } + + ColumnLayout { + id: keyOriginSection + visible: !cosigner + && wizard_data['wallet_type'] !== 'multisig' + && bitcoin.masterKeyDepth(masterkey_ta.text.trim()) > 1 + Layout.fillWidth: true + Layout.topMargin: constants.paddingMedium + spacing: constants.paddingSmall + + Heading { + text: qsTr('Key Origin Info') + } + + InfoTextArea { + Layout.fillWidth: true + text: qsTr('These fields may be required for a hardware wallet to sign the generated PSBTs.') + font.pixelSize: constants.fontSizeSmall + } + + Label { + text: qsTr('Derivation path (optional)') + font.pixelSize: constants.fontSizeSmall + } + TextField { + id: derivation_tf + Layout.fillWidth: true + placeholderText: "m/84'/0'/0'" + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + onTextChanged: { + if (anyActiveFocus) revalidate() + } + } + + Label { + text: qsTr('BIP32 master fingerprint (optional)') + font.pixelSize: constants.fontSizeSmall + } + TextField { + id: fingerprint_tf + Layout.fillWidth: true + placeholderText: qsTr('8 hex chars, e.g. deadbeef') + maximumLength: 8 + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase + onTextChanged: { + if (anyActiveFocus) revalidate() + } + } + + InfoTextArea { + id: keyOriginError + Layout.fillWidth: true + visible: text !== '' + iconStyle: InfoTextArea.IconStyle.Error + font.pixelSize: constants.fontSizeSmall + } + } } Bitcoin { diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py index 2f1f0df409f..06b692b8031 100644 --- a/electrum/gui/qml/qebitcoin.py +++ b/electrum/gui/qml/qebitcoin.py @@ -5,7 +5,7 @@ from electrum import mnemonic from electrum import keystore from electrum.i18n import _ -from electrum.bip32 import is_bip32_derivation, xpub_type +from electrum.bip32 import is_bip32_derivation, xpub_type, is_xkey_consistent_with_key_origin_info, BIP32Node from electrum.logging import get_logger from electrum.util import get_asyncio_loop from electrum.transaction import tx_from_any @@ -98,6 +98,38 @@ def verifyMasterKey(self, key, wallet_type='standard'): def verifyDerivationPath(self, path): return is_bip32_derivation(path) + @pyqtSlot(str, str, str, result=str) + def verifyKeyOriginInfo(self, xpub: str, derivation: str, fingerprint: str) -> str: + derivation = derivation.strip() + fingerprint = fingerprint.strip().lower() + if not derivation and not fingerprint: + return '' + if fingerprint and (len(fingerprint) != 8 + or not all(c in '0123456789abcdef' for c in fingerprint)): + return _('BIP32 fingerprint must be exactly 8 hex characters (e.g. deadbeef)') + if derivation and not is_bip32_derivation(derivation): + return _("Invalid derivation path (e.g. m/84'/0'/0')") + if xpub and keystore.is_master_key(xpub): + try: + consistent = is_xkey_consistent_with_key_origin_info( + xpub, + derivation_prefix=derivation or None, + root_fingerprint=fingerprint or None, + ) + if not consistent: + return _('Derivation path is inconsistent with the master key ' + '(check depth and account index)') + except Exception: + return _('Could not validate key origin info against the master key') + return '' + + @pyqtSlot(str, result=int) + def masterKeyDepth(self, key: str) -> int: + try: + return BIP32Node.from_xkey(key.strip()).depth + except Exception: + return -1 + @pyqtSlot(str, result=bool) def isRawTx(self, rawtx): try: diff --git a/electrum/wizard.py b/electrum/wizard.py index 6b2e7b65d2c..d1d7c0c1157 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -749,6 +749,18 @@ def create_storage(self, path: str, data: dict): else: if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: raise UserFacingException(_('Wrong key type {}').format(t1)) + _der = data.get('key_origin_derivation', '').strip() + _fp = data.get('key_origin_fingerprint', '').strip().lower() + if _der or _fp: + try: + k.add_key_origin( + derivation_prefix=_der or None, + root_fingerprint=_fp or None, + ) + except Exception as e: + raise UserFacingException( + _('Invalid key origin info: {}').format(e) + ) from e elif isinstance(k, keystore.Old_KeyStore): pass else: