dash/test
pasta f5bf5ce77a
Merge #6116: fix: mitigate crashes associated with some upgradetohd edge cases
69c37f4ec2 rpc: make sure `upgradetohd` always has the passphrase for `UpgradeToHD` (Kittywhiskers Van Gogh)
619b640a77 wallet: unify HD chain generation in CWallet (Kittywhiskers Van Gogh)
163d31861c 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 (19512988c6), 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](9456d0761d/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](1394c41c8d/src/wallet/wallet.cpp (L5498-L5500)) and [here](1394c41c8d/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 69c37f4ec2
  UdjinM6:
    utACK 69c37f4ec2
  PastaPastaPasta:
    utACK 69c37f4ec2

Tree-SHA512: 4bda1f7155511447d6672bbaa22b909f5e2fc7efd1fd8ae1c61e0cdbbf3f6c28f6e8c1a8fe2a270fdedff7279322c93bf0f8e01890aff556fb17288ef6907b3e
2024-07-19 11:33:58 -05:00
..
functional Merge #6116: fix: mitigate crashes associated with some upgradetohd edge cases 2024-07-19 11:33:58 -05:00
fuzz Merge #21012: ci: Fuzz with integer sanitizer 2024-06-20 12:19:21 +07:00
lint refactor: drop usage of chainstate globals in asset locks logic 2024-06-26 13:50:48 +00:00
sanitizer_suppressions Merge #21012: ci: Fuzz with integer sanitizer 2024-06-20 12:19:21 +07:00
util merge bitcoin#20286: deprecate addresses and reqSigs from rpc outputs 2024-06-27 19:27:37 +00:00
config.ini.in
get_previous_releases.py fix: follow-up partial bitcoin/bitcoin#25063 - actually load binaries with x86_64-apple-darwin platform 2023-11-24 11:23:46 -06:00
README.md Merge bitcoin/bitcoin#21740: test: add new python linter to check file names and permissions 2024-05-16 02:09:37 +07:00

This directory contains integration tests that test dashd and its utilities in their entirety. It does not contain unit tests, which can be found in /src/test, /src/wallet/test, etc.

This directory contains the following sets of tests:

  • functional which test the functionality of dashd and dash-qt by interacting with them through the RPC and P2P interfaces.
  • util which tests the dash utilities, currently only dash-tx.
  • lint which perform various static analysis checks.

The util tests are run as part of make check target. The functional tests and lint scripts can be run as explained in the sections below.

Running tests locally

Before tests can be run locally, Dash Core must be built. See the building instructions for help.

Functional tests

Dependencies and prerequisites

Many Dash specific tests require dash_hash. To install it:

  • Clone the repo git clone https://github.com/dashpay/dash_hash
  • Install dash_hash cd dash_hash && pip3 install -r requirements.txt .

The ZMQ functional test requires a python ZMQ library. To install it:

  • on Unix, run sudo apt-get install python3-zmq
  • on mac OS, run pip3 install pyzmq

On Windows the PYTHONUTF8 environment variable must be set to 1:

set PYTHONUTF8=1

Running the tests

Individual tests can be run by directly calling the test script, e.g.:

test/functional/wallet_hd.py

or can be run through the test_runner harness, eg:

test/functional/test_runner.py wallet_hd.py

You can run any combination (incl. duplicates) of tests by calling:

test/functional/test_runner.py <testname1> <testname2> <testname3> ...

Wildcard test names can be passed, if the paths are coherent and the test runner is called from a bash shell or similar that does the globbing. For example, to run all the wallet tests:

test/functional/test_runner.py test/functional/wallet*
functional/test_runner.py functional/wallet* (called from the test/ directory)
test_runner.py wallet* (called from the test/functional/ directory)

but not

test/functional/test_runner.py wallet*

Combinations of wildcards can be passed:

test/functional/test_runner.py ./test/functional/tool* test/functional/mempool*
test_runner.py tool* mempool*

Run the regression test suite with:

test/functional/test_runner.py

Run all possible tests with

test/functional/test_runner.py --extended

In order to run backwards compatibility tests, download the previous node binaries:

test/get_previous_releases.py -b v19.3.0 v18.2.2 v0.17.0.3 v0.16.1.1 v0.15.0.0

By default, up to 4 tests will be run in parallel by test_runner. To specify how many jobs to run, append --jobs=n

The individual tests and the test_runner harness have many command-line options. Run test/functional/test_runner.py -h to see them all.

Speed up test runs with a ramdisk

If you have available RAM on your system you can create a ramdisk to use as the cache and tmp directories for the functional tests in order to speed them up. Speed-up amount varies on each system (and according to your ram speed and other variables), but a 2-3x speed-up is not uncommon.

To create a 4GB ramdisk on Linux at /mnt/tmp/:

sudo mkdir -p /mnt/tmp
sudo mount -t tmpfs -o size=4g tmpfs /mnt/tmp/

Configure the size of the ramdisk using the size= option. The size of the ramdisk needed is relative to the number of concurrent jobs the test suite runs. For example running the test suite with --jobs=100 might need a 16GB ramdisk, but running with --jobs=4 will only need a 4GB ramdisk.

To use, run the test suite specifying the ramdisk as the cachedir and tmpdir:

test/functional/test_runner.py --cachedir=/mnt/tmp/cache --tmpdir=/mnt/tmp

Once finished with the tests and the disk, and to free the ram, simply unmount the disk:

sudo umount /mnt/tmp

Troubleshooting and debugging test failures

Resource contention

The P2P and RPC ports used by the dashd nodes-under-test are chosen to make conflicts with other processes unlikely. However, if there is another dashd process running on the system (perhaps from a previous test which hasn't successfully killed all its dashd nodes), then there may be a port conflict which will cause the test to fail. It is recommended that you run the tests on a system where no other dashd processes are running.

On linux, the test framework will warn if there is another dashd process running when the tests are started.

If there are zombie dashd processes after test failure, you can kill them by running the following commands. Note that these commands will kill all dashd processes running on the system, so should not be used if any non-test dashd processes are being run.

killall dashd

or

pkill -9 dashd
Data directory cache

A pre-mined blockchain with 200 blocks is generated the first time a functional test is run and is stored in test/cache. This speeds up test startup times since new blockchains don't need to be generated for each test. However, the cache may get into a bad state, in which case tests will fail. If this happens, remove the cache directory (and make sure dashd processes are stopped as above):

rm -rf test/cache
killall dashd
Test logging

The tests contain logging at five different levels (DEBUG, INFO, WARNING, ERROR and CRITICAL). From within your functional tests you can log to these different levels using the logger included in the test_framework, e.g. self.log.debug(object). By default:

  • when run through the test_runner harness, all logs are written to test_framework.log and no logs are output to the console.
  • when run directly, all logs are written to test_framework.log and INFO level and above are output to the console.
  • when run by our CI (Continuous Integration), no logs are output to the console. However, if a test fails, the test_framework.log and dashd debug.logs will all be dumped to the console to help troubleshooting.

These log files can be located under the test data directory (which is always printed in the first line of test output):

  • <test data directory>/test_framework.log
  • <test data directory>/node<node number>/regtest/debug.log.

The node number identifies the relevant test node, starting from node0, which corresponds to its position in the nodes list of the specific test, e.g. self.nodes[0].

To change the level of logs output to the console, use the -l command line argument.

test_framework.log and dashd debug.logs can be combined into a single aggregate log by running the combine_logs.py script. The output can be plain text, colorized text or html. For example:

test/functional/combine_logs.py -c <test data directory> | less -r

will pipe the colorized logs from the test into less.

Use --tracerpc to trace out all the RPC calls and responses to the console. For some tests (eg any that use submitblock to submit a full block over RPC), this can result in a lot of screen output.

By default, the test data directory will be deleted after a successful run. Use --nocleanup to leave the test data directory intact. The test data directory is never deleted after a failed test.

Attaching a debugger

A python debugger can be attached to tests at any point. Just add the line:

import pdb; pdb.set_trace()

anywhere in the test. You will then be able to inspect variables, as well as call methods that interact with the dashd nodes-under-test.

If further introspection of the dashd instances themselves becomes necessary, this can be accomplished by first setting a pdb breakpoint at an appropriate location, running the test to that point, then using gdb (or lldb on macOS) to attach to the process and debug.

For instance, to attach to self.node[1] during a run you can get the pid of the node within pdb.

(pdb) self.node[1].process.pid

Alternatively, you can find the pid by inspecting the temp folder for the specific test you are running. The path to that folder is printed at the beginning of every test run:

2017-06-27 14:13:56.686000 TestFramework (INFO): Initializing test directory /tmp/user/1000/testo9vsdjo3

Use the path to find the pid file in the temp folder:

cat /tmp/user/1000/testo9vsdjo3/node1/regtest/dashd.pid

Then you can use the pid to start gdb:

gdb /home/example/dashd <pid>

Note: gdb attach step may require ptrace_scope to be modified, or sudo preceding the gdb. See this link for considerations: https://www.kernel.org/doc/Documentation/security/Yama.txt

Often while debugging rpc calls from functional tests, the test might reach timeout before process can return a response. Use --timeout-factor 0 to disable all rpc timeouts for that partcular functional test. Ex: test/functional/wallet_hd.py --timeout-factor 0.

Profiling

An easy way to profile node performance during functional tests is provided for Linux platforms using perf.

Perf will sample the running node and will generate profile data in the node's datadir. The profile data can then be presented using perf report or a graphical tool like hotspot.

To generate a profile during test suite runs, use the --perf flag.

To see render the output to text, run

perf report -i /path/to/datadir/send-big-msgs.perf.data.xxxx --stdio | c++filt | less

For ways to generate more granular profiles, see the README in test/functional.

Util tests

Util tests can be run locally by running test/util/bitcoin-util-test.py. Use the -v option for verbose output.

Lint tests

Dependencies

Lint test Dependency Version used by CI Installation
lint-python.sh flake8 3.8.3 pip3 install flake8==3.8.3
lint-python.sh mypy 0.781 pip3 install mypy==0.781
lint-shell.sh ShellCheck 0.7.2 details...
lint-shell.sh yq default pip3 install yq
lint-spelling.sh codespell 2.0.0 pip3 install codespell==2.0.0

Please be aware that on Linux distributions all dependencies are usually available as packages, but could be outdated.

Running the tests

Individual tests can be run by directly calling the test script, e.g.:

test/lint/lint-files.sh

You can run all the shell-based lint tests by running:

test/lint/lint-all.sh

Writing functional tests

You are encouraged to write functional tests for new or existing features. Further information about the functional test framework and individual tests is found in test/functional.