2021-01-25 04:03:33 +01:00
#!/usr/bin/env python3
# Copyright (c) 2016 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""
wallet_upgradetohd . py
Test upgrade to a Hierarchical Deterministic wallet via upgradetohd rpc
"""
import shutil
import os
from test_framework . test_framework import BitcoinTestFramework
from test_framework . util import (
assert_equal ,
assert_raises_rpc_error ,
)
class WalletUpgradeToHDTest ( BitcoinTestFramework ) :
def set_test_params ( self ) :
self . num_nodes = 1
2024-02-09 18:36:14 +01:00
self . extra_args = [ [ ' -usehd=0 ' ] ]
2021-01-25 04:03:33 +01:00
2018-09-13 12:33:15 +02:00
def skip_test_if_missing_module ( self ) :
self . skip_if_no_wallet ( )
2021-01-25 04:03:33 +01:00
def setup_network ( self ) :
2024-02-09 18:36:14 +01:00
self . add_nodes ( self . num_nodes , self . extra_args )
2021-01-25 04:03:33 +01:00
self . start_nodes ( )
2018-11-02 16:41:29 +01:00
self . import_deterministic_coinbase_privkeys ( )
2021-01-25 04:03:33 +01:00
def recover_non_hd ( self ) :
self . log . info ( " Recover non-HD wallet to check different upgrade paths " )
node = self . nodes [ 0 ]
self . stop_node ( 0 )
2023-02-14 09:48:36 +01:00
shutil . copyfile ( os . path . join ( node . datadir , " non_hd.bak " ) , os . path . join ( node . datadir , self . chain , self . default_wallet_name , self . wallet_data_filename ) )
2021-01-25 04:03:33 +01:00
self . start_node ( 0 )
2021-08-27 21:03:02 +02:00
assert ' hdchainid ' not in node . getwalletinfo ( )
2021-01-25 04:03:33 +01:00
def run_test ( self ) :
node = self . nodes [ 0 ]
node . backupwallet ( os . path . join ( node . datadir , " non_hd.bak " ) )
self . log . info ( " No mnemonic, no mnemonic passphrase, no wallet passphrase " )
2021-08-27 21:03:02 +02:00
assert ' hdchainid ' not in node . getwalletinfo ( )
2021-01-25 04:03:33 +01:00
balance_before = node . getbalance ( )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( )
2021-01-25 04:03:33 +01:00
mnemonic = node . dumphdinfo ( ) [ ' mnemonic ' ]
chainid = node . getwalletinfo ( ) [ ' hdchainid ' ]
assert_equal ( len ( chainid ) , 64 )
assert_equal ( balance_before , node . getbalance ( ) )
self . log . info ( " Should be spendable and should use correct paths " )
for i in range ( 5 ) :
txid = node . sendtoaddress ( node . getnewaddress ( ) , 1 )
outs = node . decoderawtransaction ( node . gettransaction ( txid ) [ ' hex ' ] ) [ ' vout ' ]
for out in outs :
if out [ ' value ' ] == 1 :
2024-06-23 17:51:21 +02:00
keypath = node . getaddressinfo ( out [ ' scriptPubKey ' ] [ ' address ' ] ) [ ' hdkeypath ' ]
2021-01-25 04:03:33 +01:00
assert_equal ( keypath , " m/44 ' /1 ' /0 ' /0/ %d " % i )
else :
2024-06-23 17:51:21 +02:00
keypath = node . getaddressinfo ( out [ ' scriptPubKey ' ] [ ' address ' ] ) [ ' hdkeypath ' ]
2021-01-25 04:03:33 +01:00
assert_equal ( keypath , " m/44 ' /1 ' /0 ' /1/ %d " % i )
self . bump_mocktime ( 1 )
node . generate ( 1 )
self . log . info ( " Should no longer be able to start it with HD disabled " )
self . stop_node ( 0 )
2023-02-14 09:48:36 +01:00
node . assert_start_raises_init_error ( [ ' -usehd=0 ' ] , " Error: Error loading %s : You can ' t disable HD on an already existing HD wallet " % self . default_wallet_name )
2024-02-09 18:36:14 +01:00
self . extra_args = [ ]
self . start_node ( 0 , [ ] )
2021-01-25 04:03:33 +01:00
balance_after = node . getbalance ( )
self . recover_non_hd ( )
# We spent some coins from non-HD keys to HD ones earlier
balance_non_HD = node . getbalance ( )
2021-08-27 21:03:02 +02:00
assert balance_before != balance_non_HD
2021-01-25 04:03:33 +01:00
self . log . info ( " No mnemonic, no mnemonic passphrase, no wallet passphrase, should result in completely different keys " )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( )
assert mnemonic != node . dumphdinfo ( ) [ ' mnemonic ' ]
assert chainid != node . getwalletinfo ( ) [ ' hdchainid ' ]
2021-01-25 04:03:33 +01:00
assert_equal ( balance_non_HD , node . getbalance ( ) )
node . keypoolrefill ( 5 )
node . rescanblockchain ( )
# Completely different keys, no HD coins should be recovered
assert_equal ( balance_non_HD , node . getbalance ( ) )
self . recover_non_hd ( )
2022-05-17 00:51:54 +02:00
self . log . info ( " No mnemonic, no mnemonic passphrase, no wallet passphrase, should result in completely different keys " )
2020-06-22 18:24:41 +02:00
self . restart_node ( 0 , extra_args = [ ' -keypool=10 ' ] )
2022-05-17 00:51:54 +02:00
assert node . upgradetohd ( " " , " " , " " , True )
# Completely different keys, no HD coins should be recovered
assert mnemonic != node . dumphdinfo ( ) [ ' mnemonic ' ]
assert chainid != node . getwalletinfo ( ) [ ' hdchainid ' ]
assert_equal ( balance_non_HD , node . getbalance ( ) )
self . recover_non_hd ( )
2021-01-25 04:03:33 +01:00
self . log . info ( " Same mnemonic, another mnemonic passphrase, no wallet passphrase, should result in a different set of keys " )
new_mnemonic_passphrase = " somewords "
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( mnemonic , new_mnemonic_passphrase )
2021-01-25 04:03:33 +01:00
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
new_chainid = node . getwalletinfo ( ) [ ' hdchainid ' ]
2021-08-27 21:03:02 +02:00
assert chainid != new_chainid
2021-01-25 04:03:33 +01:00
assert_equal ( balance_non_HD , node . getbalance ( ) )
node . keypoolrefill ( 5 )
node . rescanblockchain ( )
# A different set of keys, no HD coins should be recovered
new_addresses = ( node . getnewaddress ( ) , node . getrawchangeaddress ( ) )
assert_equal ( balance_non_HD , node . getbalance ( ) )
self . recover_non_hd ( )
self . log . info ( " Same mnemonic, another mnemonic passphrase, no wallet passphrase, should result in a different set of keys (again) " )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( mnemonic , new_mnemonic_passphrase )
2021-01-25 04:03:33 +01:00
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( new_chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
assert_equal ( balance_non_HD , node . getbalance ( ) )
node . keypoolrefill ( 5 )
node . rescanblockchain ( )
# A different set of keys, no HD coins should be recovered, keys should be the same as they were the previous time
assert_equal ( new_addresses , ( node . getnewaddress ( ) , node . getrawchangeaddress ( ) ) )
assert_equal ( balance_non_HD , node . getbalance ( ) )
self . recover_non_hd ( )
self . log . info ( " Same mnemonic, no mnemonic passphrase, no wallet passphrase, should recover all coins after rescan " )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( mnemonic )
2021-01-25 04:03:33 +01:00
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
node . keypoolrefill ( 5 )
2022-05-17 00:51:54 +02:00
assert balance_after != node . getbalance ( )
2021-01-25 04:03:33 +01:00
node . rescanblockchain ( )
assert_equal ( balance_after , node . getbalance ( ) )
self . recover_non_hd ( )
self . log . info ( " Same mnemonic, no mnemonic passphrase, no wallet passphrase, large enough keepool, should recover all coins with no extra rescan " )
2020-06-22 18:24:41 +02:00
self . restart_node ( 0 , extra_args = [ ' -keypool=10 ' ] )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( mnemonic )
2021-01-25 04:03:33 +01:00
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
# All coins should be recovered
assert_equal ( balance_after , node . getbalance ( ) )
self . recover_non_hd ( )
2022-05-17 00:51:54 +02:00
self . log . info ( " Same mnemonic, no mnemonic passphrase, no wallet passphrase, large enough keepool, rescan is skipped initially, should recover all coins after rescanblockchain " )
2020-06-22 18:24:41 +02:00
self . restart_node ( 0 , extra_args = [ ' -keypool=10 ' ] )
2022-05-17 00:51:54 +02:00
assert node . upgradetohd ( mnemonic , " " , " " , False )
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
assert balance_after != node . getbalance ( )
node . rescanblockchain ( )
# All coins should be recovered
assert_equal ( balance_after , node . getbalance ( ) )
self . recover_non_hd ( )
2021-01-25 04:03:33 +01:00
self . log . info ( " Same mnemonic, same mnemonic passphrase, encrypt wallet on upgrade, should recover all coins after rescan " )
walletpass = " 111pass222 "
2018-09-14 10:28:27 +02:00
assert node . upgradetohd ( mnemonic , " " , walletpass )
node . stop ( )
2021-01-25 04:03:33 +01:00
node . wait_until_stopped ( )
self . start_node ( 0 , extra_args = [ ' -rescan ' ] )
assert_raises_rpc_error ( - 13 , " Error: Please enter the wallet passphrase with walletpassphrase first. " , node . dumphdinfo )
node . walletpassphrase ( walletpass , 100 )
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
2021-07-17 21:15:21 +02:00
# Note: wallet encryption results in additional keypool topup,
2021-01-25 04:03:33 +01:00
# so we can't compare new balance to balance_non_HD here,
# assert_equal(balance_non_HD, node.getbalance()) # won't work
2021-08-27 21:03:02 +02:00
assert balance_non_HD != node . getbalance ( )
2021-01-25 04:03:33 +01:00
node . keypoolrefill ( 4 )
node . rescanblockchain ( )
# All coins should be recovered
assert_equal ( balance_after , node . getbalance ( ) )
self . recover_non_hd ( )
self . log . info ( " Same mnemonic, same mnemonic passphrase, encrypt wallet first, should recover all coins on upgrade after rescan " )
walletpass = " 111pass222 "
node . encryptwallet ( walletpass )
2018-09-14 10:28:27 +02:00
node . stop ( )
2021-01-25 04:03:33 +01:00
node . wait_until_stopped ( )
self . start_node ( 0 , extra_args = [ ' -rescan ' ] )
Merge #6116: fix: mitigate crashes associated with some upgradetohd edge cases
69c37f4ec2570c85f3e8b668da8bd30400a54e5f rpc: make sure `upgradetohd` always has the passphrase for `UpgradeToHD` (Kittywhiskers Van Gogh)
619b640a774337af5a3ad02551bc2585f2b0c919 wallet: unify HD chain generation in CWallet (Kittywhiskers Van Gogh)
163d31861c14d37c0a407d70d10f847050b67e37 wallet: unify HD chain generation in LegacyScriptPubKeyMan (Kittywhiskers Van Gogh)
Pull request description:
## Motivation
When filming demo footage for https://github.com/dashpay/dash/pull/6093, I realized that if I tried to create an encrypted blank legacy wallet and run `upgradetohd [mnemonic]`, the client would crash.
```
dash@b9c6631a824d:/src/dash$ ./src/qt/dash-qt
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-dash'
dash-qt: wallet/scriptpubkeyman.cpp:399: void LegacyScriptPubKeyMan::GenerateNewCryptedHDChain(const SecureString &, const SecureString &, CKeyingMaterial): Assertion `res' failed.
Posix Signal: Aborted
No debug information available for stacktrace. You should add debug information and then run:
dash-qt -printcrashinfo=bvcgc43iinzgc43ijfxgm3ybaadwiyltnawxc5avkbxxg2lyebjwsz3omfwduicbmjxxe5dfmqaaa===
```
The expected set of operations when performing privileged operations is to first use `walletpassphrase [passphrase] [time]` to unlock the wallet and then perform the privileged operation. This routine that applies for almost all privileged RPCs doesn't apply here, the unlock state of the wallet has no bearing on constructing an encrypted HD chain as it needs to be encrypted with the master key stored in the wallet, which in turn is encrypted with a key derived from the passphrase (i.e., `upgradetohd` imports **always** need the passphrase, if encrypted).
You might have noticed that I used `upgradetohd [mnemonic]` instead of the correct syntax, `upgradetohd [mnemonic] "" [passphrase]` that is supposed to be used when supplying a mnemonic to an encrypted wallet, because when you run the former, you don't get told to enter the passphrase into the RPC command, you're told.
```
Error: Please enter the wallet passphrase with walletpassphrase first.
```
Which tells you to treat it like any other routine privileged operation and follow the routine as mentioned above. This is where insufficient validation starts rearing its head, we only validate the passphrase if we're supplied one even though we should be demanding one if the wallet is encrypted and it isn't supplied. We didn't supply a passphrase because we're following the normal routine, we unlocked the wallet so `EnsureWalletIsUnlocked()` is happy, so now the following happens.
```
upgradetohd()
| Insufficient validation has allowed us to supply a blank passphrase
| for an encrypted wallet
|- CWallet::UpgradeToHD()
|- CWallet::GenerateNewHDChainEncrypted()
| We get our hands on vMasterKey by generating the key from our passphrase
| and using it to unlock vCryptedMasterKey.
|
| There's one small problem, we don't know if the output of CCrypter::Decrypt
| isn't just gibberish. Since we don't have a passphrase, whatever came from
| CCrypter::SetKeyFromPassphrase isn't the decryption key, meaning, the
| vMasterKey we just got is gibberish
|- LegacyScriptPubKeyMan::GenerateNewCryptedHDChain()
|- res = LegacyScriptPubKeyMan::EncryptHDChain()
| |- EncryptSecret()
| |- CCrypter::SetKey()
| This is where everything unravels, the gibberish key's size doesn't
| match WALLET_CRYPTO_KEY_SIZE, it's no good for encryption. We bail out.
|- assert(res)
We assume are inputs are safe so there's no real reason we should crash.
Except our inputs aren't safe, so we crash. Welp! :c
```
This problem has existed for a while but didn't cause the client to crash, in v20.1.1 (19512988c6e6e8641245bd9c5fab21dd737561f0), trying to do the same thing would return you a vague error
```
Failed to generate encrypted HD wallet (code -4)
```
In the process of working on mitigating this crash, another edge case was discovered, where if the wallet was unlocked and an incorrect passphrase was provided to `upgradetohd`, the user would not receive any feedback that they entered the wrong passphrase and the client would similarly crash.
```
upgradetohd()
| We've been supplied a passphrase, so we can try and validate it by
| trying to unlock the wallet with it. If it fails, we know we got the
| wrong passphrase.
|- CWallet::Unlock()
| | Before we bother unlocking the wallet, we should check if we're
| | already unlocked, if we are, we can just say "unlock successful".
| |- CWallet::IsLocked()
| | Wallet is indeed unlocked.
| |- return true;
| The validation method we just tried to use has a bail-out mechanism
| that we don't account for, the "unlock" succeded so I guess we have the
| right passphrase.
[...] (continue call chain as mentioned earlier)
|- assert(res)
Oh...
```
This pull request aims to resolve crashes caused by the above two edge cases.
## Additional Information
As this PR was required me to add additional guardrails on `GenerateNewCryptedHDChain()` and `GenerateNewHDChainEncrypted()`, it was taken as an opportunity to resolve a TODO ([source](https://github.com/dashpay/dash/blob/9456d0761d8883cc293dffba11dacded517b5f8f/src/wallet/wallet.cpp#L5028-L5038)). The following mitigations have been implemented.
* Validating `vMasterKey` size (any key not of `WALLET_CRYPTO_KEY_SIZE` size cannot be used for encryption and so, cannot be a valid key)
* Validating `secureWalletPassphrase`'s presence to catch attempts at passing a blank value (an encrypted wallet cannot have a blank passphrase)
* Using `Unlock()` to validate the correctness of `vMasterKey`. (the two other instances of iterating through `mapMasterKeys` use `Unlock()`, see [here](https://github.com/dashpay/dash/blob/1394c41c8d0afb8370726488a2888be30d238148/src/wallet/wallet.cpp#L5498-L5500) and [here](https://github.com/dashpay/dash/blob/1394c41c8d0afb8370726488a2888be30d238148/src/wallet/wallet.cpp#L429-L431))
* `Lock()`'ing the wallet before `Unlock()`'ing the wallet to avoid the `IsLocked()` bail-out condition and then restoring to the previous lock state afterwards.
* Add an `IsCrypted()` check to see if `upgradetohd`'s `walletpassphrase` is allowed to be empty.
## Checklist:
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have added or updated relevant unit/integration/functional/e2e tests
- [x] I have made corresponding changes to the documentation **(note: N/A)**
- [x] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_
ACKs for top commit:
knst:
utACK 69c37f4ec2570c85f3e8b668da8bd30400a54e5f
UdjinM6:
utACK 69c37f4ec2570c85f3e8b668da8bd30400a54e5f
PastaPastaPasta:
utACK 69c37f4ec2570c85f3e8b668da8bd30400a54e5f
Tree-SHA512: 4bda1f7155511447d6672bbaa22b909f5e2fc7efd1fd8ae1c61e0cdbbf3f6c28f6e8c1a8fe2a270fdedff7279322c93bf0f8e01890aff556fb17288ef6907b3e
2024-07-19 18:32:47 +02:00
assert_raises_rpc_error ( - 13 , " Error: Wallet encrypted but passphrase not supplied to RPC. " , node . upgradetohd , mnemonic )
assert_raises_rpc_error ( - 1 , " Error: The wallet passphrase entered was incorrect " , node . upgradetohd , mnemonic , " " , " wrongpass " )
2021-08-27 21:03:02 +02:00
assert node . upgradetohd ( mnemonic , " " , walletpass )
2021-01-25 04:03:33 +01:00
assert_raises_rpc_error ( - 13 , " Error: Please enter the wallet passphrase with walletpassphrase first. " , node . dumphdinfo )
node . walletpassphrase ( walletpass , 100 )
assert_equal ( mnemonic , node . dumphdinfo ( ) [ ' mnemonic ' ] )
assert_equal ( chainid , node . getwalletinfo ( ) [ ' hdchainid ' ] )
2021-07-17 21:15:21 +02:00
# Note: wallet encryption results in additional keypool topup,
2021-01-25 04:03:33 +01:00
# so we can't compare new balance to balance_non_HD here,
# assert_equal(balance_non_HD, node.getbalance()) # won't work
2021-08-27 21:03:02 +02:00
assert balance_non_HD != node . getbalance ( )
2021-01-25 04:03:33 +01:00
node . keypoolrefill ( 4 )
node . rescanblockchain ( )
# All coins should be recovered
assert_equal ( balance_after , node . getbalance ( ) )
if __name__ == ' __main__ ' :
WalletUpgradeToHDTest ( ) . main ( )